diff --git a/android/app/build.gradle b/android/app/build.gradle index 9b62aec..716dc49 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -44,20 +44,15 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId "com.example.sonnat" - // You can update the following values to match your application needs. - // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. - minSdkVersion flutter.minSdkVersion - targetSdkVersion flutter.targetSdkVersion + minSdkVersion 16 + targetSdkVersion 33 versionCode flutterVersionCode.toInteger() versionName flutterVersionName } buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. signingConfig signingConfigs.debug } } @@ -68,5 +63,5 @@ flutter { } dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.8.0" } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b82084a..046b655 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,31 +1,25 @@ + + android:icon="@mipmap/ic_launcher" + android:label="sonnat"> - + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> - - + + - diff --git a/android/build.gradle b/android/build.gradle index f7eb7f6..baa7dd2 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,12 +1,12 @@ buildscript { - ext.kotlin_version = '1.7.10' + ext.kotlin_version = '1.8.0' repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:7.3.0' + classpath 'com.android.tools.build:gradle:7.3.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/assets/lottie/loading.json b/assets/lottie/loading.json new file mode 100644 index 0000000..7dc666e --- /dev/null +++ b/assets/lottie/loading.json @@ -0,0 +1,4007 @@ +{ + "v": "4.8.0", + "meta": { + "g": "LottieFiles AE ", + "a": "", + "k": "", + "d": "", + "tc": "" + }, + "fr": 25, + "ip": 0, + "op": 83, + "w": 495, + "h": 420, + "nm": "Pre-comp 4", + "ddd": 0, + "assets": [ + { + "id": "comp_0", + "layers": [ + { + "ddd": 0, + "ind": 1, + "ty": 4, + "nm": "Shape Layer 1", + "parent": 5, + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 3, + -262.25, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -262.25, + 0 + ], + "ix": 1 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.667, + 0.667, + 0.667 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 1, + 1, + 0.333 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": -13, + "s": [ + 0, + 0, + 100 + ] + }, + { + "t": 3, + "s": [ + 100, + 100, + 100 + ] + } + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 3.25, + -262 + ], + [ + -25.25, + -213.5 + ], + [ + 25.5, + -196.25 + ], + [ + 32, + -213 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 0, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.988, + 0.588, + 0.463, + 0.5, + 0.99, + 0.68, + 0.463, + 1, + 0.992, + 0.773, + 0.463 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -22.5, + -211 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 17.5, + -234 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Shape 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": -13, + "op": 732, + "st": -18, + "bm": 0 + }, + { + "ddd": 0, + "ind": 2, + "ty": 4, + "nm": "Shape Layer 2", + "parent": 6, + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 3.855, + -263.075, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3.855, + -263.075, + 0 + ], + "ix": 1 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.667, + 0.667, + 0.667 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 1, + 1, + 0.333 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": -13, + "s": [ + 0, + 0, + 100 + ] + }, + { + "t": 3, + "s": [ + 100, + 100, + 100 + ] + } + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 3.25, + -262 + ], + [ + -25.25, + -213.5 + ], + [ + -16.361, + -192.956 + ], + [ + 22.025, + -189.783 + ], + [ + 32, + -213 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 0, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.988, + 0.62, + 0.463, + 0.5, + 0.992, + 0.684, + 0.465, + 1, + 0.996, + 0.749, + 0.467 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -22.828, + -213.888 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 24.558, + -229.942 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Shape 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": -13, + "op": 722, + "st": -28, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "Shape Layer 3", + "parent": 7, + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 3.187, + -262.273, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3.187, + -262.273, + 0 + ], + "ix": 1 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.667, + 0.667, + 0.667 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 1, + 1, + 0.333 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": -13, + "s": [ + 0, + 0, + 100 + ] + }, + { + "t": 3, + "s": [ + 100, + 100, + 100 + ] + } + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 3.25, + -262 + ], + [ + -25.25, + -213.5 + ], + [ + -17.381, + -196.057 + ], + [ + -10.234, + -201.528 + ], + [ + 28.241, + -204.536 + ], + [ + 32, + -213 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 0, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.988, + 0.624, + 0.463, + 0.5, + 0.99, + 0.694, + 0.463, + 1, + 0.992, + 0.765, + 0.463 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -8.025, + -201.891 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 1.837, + -266.327 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Shape 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": -13, + "op": 717, + "st": -33, + "bm": 0 + }, + { + "ddd": 0, + "ind": 4, + "ty": 4, + "nm": "Shape Layer 4", + "parent": 8, + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 4.215, + -262.728, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 4.215, + -262.728, + 0 + ], + "ix": 1 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.667, + 0.667, + 0.667 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 1, + 1, + 0.333 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": -13, + "s": [ + 0, + 0, + 100 + ] + }, + { + "t": 3, + "s": [ + 100, + 100, + 100 + ] + } + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 3.25, + -262 + ], + [ + -25.25, + -213.5 + ], + [ + -12.708, + -224.714 + ], + [ + 19.791, + -233.827 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 0, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.988, + 0.627, + 0.463, + 0.5, + 0.99, + 0.69, + 0.463, + 1, + 0.992, + 0.753, + 0.463 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + 8.417, + -226.809 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + -8.012, + -252.358 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Shape 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": -13, + "op": 712, + "st": -38, + "bm": 0 + }, + { + "ddd": 0, + "ind": 5, + "ty": 4, + "nm": "Shape Layer 14", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 251, + 185.75, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -145.25, + 0 + ], + "ix": 1 + }, + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.667, + 0.667, + 0.667 + ], + "y": [ + 1, + 1, + 1 + ] + }, + "o": { + "x": [ + 1, + 1, + 0.333 + ], + "y": [ + 0, + 0, + 0 + ] + }, + "t": 8, + "s": [ + 0, + 0, + 100 + ] + }, + { + "t": 20, + "s": [ + 100, + 100, + 100 + ] + } + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 3.25, + -262 + ], + [ + -25.25, + -213.5 + ], + [ + 3, + -145.25 + ], + [ + 32, + -213 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 0, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.988, + 0.459, + 0.733, + 0.5, + 0.963, + 0.447, + 0.771, + 1, + 0.937, + 0.435, + 0.808 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + 5, + -136 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 7, + -250 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Shape 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 8, + "op": 753, + "st": 3, + "bm": 0 + }, + { + "ddd": 0, + "ind": 6, + "ty": 4, + "nm": "Shape Layer 13", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 46, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 251, + 201.25, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -129.75, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 3.25, + -262 + ], + [ + -25.25, + -213.5 + ], + [ + 3, + -145.25 + ], + [ + 32, + -213 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 0, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.988, + 0.459, + 0.733, + 0.5, + 0.963, + 0.447, + 0.771, + 1, + 0.937, + 0.435, + 0.808 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + 18.692, + -144.598 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 53.162, + -252.365 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Shape 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 51, + "op": 786, + "st": 36, + "bm": 0 + }, + { + "ddd": 0, + "ind": 7, + "ty": 4, + "nm": "Shape Layer 12", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 91, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 251, + 201.25, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -129.75, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 3.25, + -262 + ], + [ + -25.25, + -213.5 + ], + [ + 3, + -145.25 + ], + [ + 32, + -213 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 0, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.988, + 0.459, + 0.733, + 0.5, + 0.963, + 0.447, + 0.771, + 1, + 0.937, + 0.435, + 0.808 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 100, + 0 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Shape 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 46, + "op": 776, + "st": 26, + "bm": 0 + }, + { + "ddd": 0, + "ind": 8, + "ty": 4, + "nm": "Shape Layer 15", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 136, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 251, + 201.25, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -129.75, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 3.25, + -262 + ], + [ + -25.25, + -213.5 + ], + [ + 3, + -145.25 + ], + [ + 32, + -213 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 0, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.361, + 0.51, + 0.996, + 0.5, + 0.58, + 0.518, + 0.986, + 1, + 0.8, + 0.525, + 0.976 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + 36.993, + -210.335 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + -27.935, + -218.771 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Shape 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 41, + "op": 766, + "st": 16, + "bm": 0 + }, + { + "ddd": 0, + "ind": 9, + "ty": 4, + "nm": "Shape Layer 5", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 180, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 251, + 201.25, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -129.75, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 3.25, + -262 + ], + [ + -25.25, + -213.5 + ], + [ + 3, + -145.25 + ], + [ + 32, + -213 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 0, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.376, + 0.557, + 0.992, + 0.5, + 0.267, + 0.657, + 0.906, + 1, + 0.157, + 0.757, + 0.82 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + -15, + -171.5 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 29, + -224 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Shape 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 36, + "op": 756, + "st": 6, + "bm": 0 + }, + { + "ddd": 0, + "ind": 10, + "ty": 4, + "nm": "Shape Layer 6", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 225, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 251, + 201.25, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -129.75, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 3.25, + -262 + ], + [ + -25.25, + -213.5 + ], + [ + 3, + -145.25 + ], + [ + 32, + -213 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 0, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.376, + 0.557, + 0.992, + 0.5, + 0.267, + 0.657, + 0.906, + 1, + 0.157, + 0.757, + 0.82 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + 0.707, + -160.513 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 10.905, + -229.103 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Shape 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 31, + "op": 746, + "st": -4, + "bm": 0 + }, + { + "ddd": 0, + "ind": 11, + "ty": 4, + "nm": "Shape Layer 7", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 270, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 251, + 201.25, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -129.75, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 3.25, + -262 + ], + [ + -25.25, + -213.5 + ], + [ + 3, + -145.25 + ], + [ + 32, + -213 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 0, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.376, + 0.557, + 0.992, + 0.5, + 0.267, + 0.657, + 0.906, + 1, + 0.157, + 0.757, + 0.82 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + 16, + -145 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + 6, + -269 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Shape 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 26, + "op": 736, + "st": -14, + "bm": 0 + }, + { + "ddd": 0, + "ind": 12, + "ty": 4, + "nm": "Shape Layer 8", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 315, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 251, + 201.25, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -129.75, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ind": 0, + "ty": "sh", + "ix": 1, + "ks": { + "a": 0, + "k": { + "i": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "o": [ + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ], + [ + 0, + 0 + ] + ], + "v": [ + [ + 3.25, + -262 + ], + [ + -25.25, + -213.5 + ], + [ + 3, + -145.25 + ], + [ + 32, + -213 + ] + ], + "c": true + }, + "ix": 2 + }, + "nm": "Path 1", + "mn": "ADBE Vector Shape - Group", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 1, + 1, + 1, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 0, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "gf", + "o": { + "a": 0, + "k": 100, + "ix": 10 + }, + "r": 1, + "bm": 0, + "g": { + "p": 3, + "k": { + "a": 0, + "k": [ + 0, + 0.898, + 0.416, + 0.859, + 0.5, + 0.943, + 0.437, + 0.796, + 1, + 0.988, + 0.459, + 0.733 + ], + "ix": 9 + } + }, + "s": { + "a": 0, + "k": [ + 28.991, + -195.869 + ], + "ix": 5 + }, + "e": { + "a": 0, + "k": [ + -23.744, + -211.425 + ], + "ix": 6 + }, + "t": 1, + "nm": "Gradient Fill 1", + "mn": "ADBE Vector Graphic - G-Fill", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 3, + -145.5 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Shape 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + } + ], + "ip": 21, + "op": 726, + "st": -24, + "bm": 0 + } + ] + } + ], + "layers": [ + { + "ddd": 0, + "ind": 2, + "ty": 0, + "nm": "Pre-comp 5", + "refId": "comp_0", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 247.5, + 210, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 247.5, + 210, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6 + } + }, + "ao": 0, + "w": 495, + "h": 420, + "ip": -52, + "op": 698, + "st": -52, + "bm": 0 + }, + { + "ddd": 0, + "ind": 3, + "ty": 4, + "nm": "Shape Layer 11", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 247.5, + 334, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "rc", + "d": 1, + "s": { + "a": 0, + "k": [ + 342, + 342 + ], + "ix": 2 + }, + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 122, + "ix": 4 + }, + "nm": "Rectangle Path 1", + "mn": "ADBE Vector Shape - Rect", + "hd": false + }, + { + "ty": "gs", + "o": { + "a": 0, + "k": 100, + "ix": 9 + }, + "w": { + "a": 0, + "k": 14, + "ix": 10 + }, + "g": { + "p": 5, + "k": { + "a": 0, + "k": [ + 0, + 0.114, + 0.796, + 0.722, + 0.232, + 0.537, + 0.622, + 0.747, + 0.495, + 0.961, + 0.447, + 0.773, + 0.748, + 0.975, + 0.582, + 0.631, + 1, + 0.988, + 0.718, + 0.49 + ], + "ix": 8 + } + }, + "s": { + "a": 0, + "k": [ + -120, + -6 + ], + "ix": 4 + }, + "e": { + "a": 0, + "k": [ + -43, + -157 + ], + "ix": 5 + }, + "t": 1, + "lc": 1, + "lj": 1, + "ml": 4, + "ml2": { + "a": 0, + "k": 4, + "ix": 13 + }, + "bm": 0, + "nm": "Gradient Stroke 1", + "mn": "ADBE Vector Graphic - G-Stroke", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 3, + -130 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Rectangle 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "tm", + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0.065 + ], + "y": [ + 0.728 + ] + }, + "o": { + "x": [ + 0.86 + ], + "y": [ + 0.108 + ] + }, + "t": 8, + "s": [ + 0 + ] + }, + { + "t": 32, + "s": [ + 100 + ] + } + ], + "ix": 1 + }, + "e": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0 + ], + "y": [ + 0.839 + ] + }, + "o": { + "x": [ + 0.903 + ], + "y": [ + 0.13 + ] + }, + "t": 48, + "s": [ + 0 + ] + }, + { + "t": 71, + "s": [ + 100 + ] + } + ], + "ix": 2 + }, + "o": { + "a": 0, + "k": -75, + "ix": 3 + }, + "m": 1, + "ix": 2, + "nm": "Trim Paths 1", + "mn": "ADBE Vector Filter - Trim", + "hd": false + } + ], + "ip": 3, + "op": 753, + "st": 3, + "bm": 0 + }, + { + "ddd": 0, + "ind": 5, + "ty": 4, + "nm": "Shape Layer 9", + "sr": 1, + "ks": { + "o": { + "a": 0, + "k": 100, + "ix": 11 + }, + "r": { + "a": 0, + "k": 0, + "ix": 10 + }, + "p": { + "a": 0, + "k": [ + 247.5, + 334, + 0 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100, + 100 + ], + "ix": 6 + } + }, + "ao": 0, + "shapes": [ + { + "ty": "gr", + "it": [ + { + "ty": "rc", + "d": 1, + "s": { + "a": 0, + "k": [ + 342, + 342 + ], + "ix": 2 + }, + "p": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 122, + "ix": 4 + }, + "nm": "Rectangle Path 1", + "mn": "ADBE Vector Shape - Rect", + "hd": false + }, + { + "ty": "st", + "c": { + "a": 0, + "k": [ + 0.850980392157, + 0.850980392157, + 0.850980392157, + 1 + ], + "ix": 3 + }, + "o": { + "a": 0, + "k": 100, + "ix": 4 + }, + "w": { + "a": 0, + "k": 14, + "ix": 5 + }, + "lc": 1, + "lj": 1, + "ml": 4, + "bm": 0, + "nm": "Stroke 1", + "mn": "ADBE Vector Graphic - Stroke", + "hd": false + }, + { + "ty": "tr", + "p": { + "a": 0, + "k": [ + 3, + -130 + ], + "ix": 2 + }, + "a": { + "a": 0, + "k": [ + 0, + 0 + ], + "ix": 1 + }, + "s": { + "a": 0, + "k": [ + 100, + 100 + ], + "ix": 3 + }, + "r": { + "a": 0, + "k": 0, + "ix": 6 + }, + "o": { + "a": 0, + "k": 100, + "ix": 7 + }, + "sk": { + "a": 0, + "k": 0, + "ix": 4 + }, + "sa": { + "a": 0, + "k": 0, + "ix": 5 + }, + "nm": "Transform" + } + ], + "nm": "Rectangle 1", + "np": 3, + "cix": 2, + "bm": 0, + "ix": 1, + "mn": "ADBE Vector Group", + "hd": false + }, + { + "ty": "tm", + "s": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0 + ], + "y": [ + 1 + ] + }, + "o": { + "x": [ + 1 + ], + "y": [ + 0 + ] + }, + "t": 2, + "s": [ + 0 + ] + }, + { + "t": 27, + "s": [ + 100 + ] + } + ], + "ix": 1 + }, + "e": { + "a": 1, + "k": [ + { + "i": { + "x": [ + 0 + ], + "y": [ + 1 + ] + }, + "o": { + "x": [ + 1 + ], + "y": [ + 0 + ] + }, + "t": 51, + "s": [ + 0 + ] + }, + { + "t": 76, + "s": [ + 100 + ] + } + ], + "ix": 2 + }, + "o": { + "a": 0, + "k": -75, + "ix": 3 + }, + "m": 1, + "ix": 2, + "nm": "Trim Paths 1", + "mn": "ADBE Vector Filter - Trim", + "hd": false + } + ], + "ip": 2, + "op": 752, + "st": 2, + "bm": 0 + } + ], + "markers": [] +} \ No newline at end of file diff --git a/lib/core/extensions/number_extension.dart b/lib/core/extensions/number_extension.dart new file mode 100644 index 0000000..b804f2f --- /dev/null +++ b/lib/core/extensions/number_extension.dart @@ -0,0 +1,18 @@ +import 'package:sonnat/core/utils/app_constants.dart'; + +extension NumberExtension on num { + double get sw { + if (this == 1) { + return double.maxFinite; + } + return toDouble() * AppConstants.instance.appWidth; + } + + double get sh => toDouble() * AppConstants.instance.appHeight; + + double get w => toDouble(); + + double get h => toDouble(); + + double get sp => toDouble(); +} diff --git a/lib/core/html/custom_render.dart b/lib/core/html/custom_render.dart new file mode 100644 index 0000000..fd433ec --- /dev/null +++ b/lib/core/html/custom_render.dart @@ -0,0 +1,489 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:sonnat/core/html/html_parser.dart'; +import 'package:sonnat/core/html/src/css_box_widget.dart'; +import 'package:sonnat/core/html/src/html_elements.dart'; +import 'package:sonnat/core/html/src/layout_element.dart'; +import 'package:sonnat/core/html/src/utils.dart'; +import 'package:sonnat/core/html/style.dart'; + +typedef CustomRenderMatcher = bool Function(RenderContext context); + +CustomRenderMatcher tagMatcher(String tag) => (context) { + return context.tree.element?.localName == tag; + }; + +CustomRenderMatcher blockElementMatcher() => (context) { + return (context.tree.style.display == Display.block || context.tree.style.display == Display.inlineBlock) && + (context.tree.children.isNotEmpty || context.tree.element?.localName == 'hr'); + }; + +CustomRenderMatcher listElementMatcher() => (context) { + return context.tree.style.display == Display.listItem; + }; + +CustomRenderMatcher replacedElementMatcher() => (context) { + return context.tree is ReplacedElement; + }; + +CustomRenderMatcher dataUriMatcher({String? encoding = 'base64', String? mime}) => (context) { + if (context.tree.element?.attributes == null || _src(context.tree.element!.attributes.cast()) == null) { + return false; + } + final dataUri = _dataUriFormat.firstMatch(_src(context.tree.element!.attributes.cast())!); + return dataUri != null && + dataUri.namedGroup('mime') != 'image/svg+xml' && + (mime == null || dataUri.namedGroup('mime') == mime) && + (encoding == null || dataUri.namedGroup('encoding') == ';$encoding'); + }; + +CustomRenderMatcher networkSourceMatcher({ + List schemas = const ['https', 'http'], + List? domains, + String? extension, +}) => + (context) { + if (context.tree.element?.attributes.cast() == null || _src(context.tree.element!.attributes.cast()) == null) { + return false; + } + try { + final src = Uri.parse(_src(context.tree.element!.attributes.cast())!); + return schemas.contains(src.scheme) && + (domains == null || domains.contains(src.host)) && + (extension == null || src.path.endsWith('.$extension')); + } catch (e) { + return false; + } + }; + +CustomRenderMatcher assetUriMatcher() => (context) => + context.tree.element?.attributes.cast() != null && + _src(context.tree.element!.attributes.cast()) != null && + _src(context.tree.element!.attributes.cast())!.startsWith('asset:') && + !_src(context.tree.element!.attributes.cast())!.endsWith('.svg'); + +CustomRenderMatcher textContentElementMatcher() => (context) { + return context.tree is TextContentElement; + }; + +CustomRenderMatcher interactableElementMatcher() => (context) { + return context.tree is InteractableElement; + }; + +CustomRenderMatcher layoutElementMatcher() => (context) { + return context.tree is LayoutElement; + }; + +CustomRenderMatcher verticalAlignMatcher() => (context) { + return context.tree.style.verticalAlign != null && context.tree.style.verticalAlign != VerticalAlign.baseline; + }; + +CustomRenderMatcher fallbackMatcher() => (context) { + return true; + }; + +class CustomRender { + final InlineSpan Function(RenderContext, List Function())? inlineSpan; + final Widget Function(RenderContext, List Function())? widget; + + CustomRender.inlineSpan({ + required this.inlineSpan, + }) : widget = null; + + CustomRender.widget({ + required this.widget, + }) : inlineSpan = null; +} + +class SelectableCustomRender extends CustomRender { + final TextSpan Function(RenderContext, List Function()) textSpan; + + SelectableCustomRender.fromTextSpan({ + required this.textSpan, + }) : super.inlineSpan(inlineSpan: null); +} + +CustomRender blockElementRender({Style? style, List? children}) => + CustomRender.inlineSpan(inlineSpan: (context, buildChildren) { + if (context.parser.selectable) { + return TextSpan( + style: context.style.generateTextStyle(), + children: (children as List?) ?? + context.tree.children + .expandIndexed((i, childTree) => [ + context.parser.parseTree(context, childTree), + if (i != context.tree.children.length - 1 && + childTree.style.display == Display.block && + childTree.element?.localName != 'html' && + childTree.element?.localName != 'body') + const TextSpan(text: '\n'), + ]) + .toList(), + ); + } + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: CssBoxWidget.withInlineSpanChildren( + key: context.key, + style: style ?? context.tree.style, + shrinkWrap: context.parser.shrinkWrap, + childIsReplaced: HtmlElements.replacedExternalElements.contains(context.tree.name), + children: children ?? + context.tree.children + .expandIndexed((i, childTree) => [ + context.parser.parseTree(context, childTree), + + if (i != context.tree.children.length - 1 && + childTree.style.display == Display.block && + childTree.element?.localName != 'html' && + childTree.element?.localName != 'body') + const TextSpan(text: '\n'), + ]) + .toList(), + ), + ); + }); + +CustomRender listElementRender({Style? style, Widget? child, List? children}) { + return CustomRender.inlineSpan( + inlineSpan: (context, buildChildren) { + return WidgetSpan( + child: CssBoxWidget.withInlineSpanChildren( + key: context.key, + style: style ?? context.style, + shrinkWrap: context.parser.shrinkWrap, + children: buildChildren(), + ), + ); + }, + ); +} + +CustomRender replacedElementRender({PlaceholderAlignment? alignment, TextBaseline? baseline, Widget? child}) => + CustomRender.inlineSpan( + inlineSpan: (context, buildChildren) => WidgetSpan( + alignment: alignment ?? (context.tree as ReplacedElement).alignment, + baseline: baseline ?? TextBaseline.alphabetic, + child: child ?? (context.tree as ReplacedElement).toWidget(context)!, + )); + +CustomRender textContentElementRender({String? text}) => CustomRender.inlineSpan( + inlineSpan: (context, buildChildren) => TextSpan( + style: context.style.generateTextStyle(), + text: (text ?? (context.tree as TextContentElement).text)?.transformed(context.tree.style.textTransform), + ), + ); + +CustomRender base64ImageRender() => CustomRender.widget(widget: (context, buildChildren) { + final decodedImage = base64.decode(_src(context.tree.element!.attributes.cast())!.split('base64,')[1].trim()); + precacheImage( + MemoryImage(decodedImage), + context.buildContext, + onError: (exception, stackTrace) { + context.parser.onImageError?.call(exception, stackTrace); + }, + ); + final widget = Image.memory( + decodedImage, + frameBuilder: (ctx, child, frame, _) { + if (frame == null) { + return Text(_alt(context.tree.element!.attributes.cast()) ?? '', style: context.style.generateTextStyle()); + } + return child; + }, + ); + return Builder( + key: context.key, + builder: (buildContext) { + return GestureDetector( + child: widget, + onTap: () { + if (MultipleTapGestureDetector.of(buildContext) != null) { + MultipleTapGestureDetector.of(buildContext)!.onTap?.call(); + } + context.parser.onImageTap?.call( + _src(context.tree.element!.attributes.cast())!.split('base64,')[1].trim(), + context, + context.tree.element!.attributes.cast(), + context.tree.element); + }, + ); + }); + }); + +CustomRender assetImageRender({ + double? width, + double? height, +}) => + CustomRender.widget(widget: (context, buildChildren) { + final assetPath = _src(context.tree.element!.attributes.cast())!.replaceFirst('asset:', ''); + final widget = Image.asset( + assetPath, + width: width ?? _width(context.tree.element!.attributes.cast()), + height: height ?? _height(context.tree.element!.attributes.cast()), + frameBuilder: (ctx, child, frame, _) { + if (frame == null) { + return Text(_alt(context.tree.element!.attributes.cast()) ?? '', style: context.style.generateTextStyle()); + } + return child; + }, + ); + return Builder( + key: context.key, + builder: (buildContext) { + return GestureDetector( + child: widget, + onTap: () { + if (MultipleTapGestureDetector.of(buildContext) != null) { + MultipleTapGestureDetector.of(buildContext)!.onTap?.call(); + } + context.parser.onImageTap + ?.call(assetPath, context, context.tree.element!.attributes.cast(), context.tree.element); + }, + ); + }); + }); + +CustomRender networkImageRender({ + Map? headers, + String Function(String?)? mapUrl, + double? width, + double? height, + Widget Function(String?)? altWidget, + Widget Function()? loadingWidget, +}) => + CustomRender.widget(widget: (context, buildChildren) { + final src = + mapUrl?.call(_src(context.tree.element!.attributes.cast())) ?? _src(context.tree.element!.attributes.cast())!; + Completer completer = Completer(); + if (context.parser.cachedImageSizes[src] != null) { + completer.complete(context.parser.cachedImageSizes[src]); + } else { + Image image = Image.network(src, frameBuilder: (ctx, child, frame, _) { + if (frame == null) { + if (!completer.isCompleted) { + completer.completeError('error'); + } + return child; + } else { + return child; + } + }); + + ImageStreamListener? listener; + listener = ImageStreamListener((imageInfo, synchronousCall) { + var myImage = imageInfo.image; + Size size = Size(myImage.width.toDouble(), myImage.height.toDouble()); + if (!completer.isCompleted) { + context.parser.cachedImageSizes[src] = size; + completer.complete(size); + image.image.resolve(const ImageConfiguration()).removeListener(listener!); + } + }, onError: (object, stacktrace) { + if (!completer.isCompleted) { + completer.completeError(object); + image.image.resolve(const ImageConfiguration()).removeListener(listener!); + } + }); + + image.image.resolve(const ImageConfiguration()).addListener(listener); + } + final attributes = context.tree.element!.attributes.cast(); + final widget = FutureBuilder( + future: completer.future, + initialData: context.parser.cachedImageSizes[src], + builder: (buildContext, snapshot) { + if (snapshot.hasData) { + return Container( + constraints: BoxConstraints( + maxWidth: width ?? _width(attributes) ?? snapshot.data!.width, + maxHeight: + (width ?? _width(attributes) ?? snapshot.data!.width) / _aspectRatio(attributes, snapshot)), + child: AspectRatio( + aspectRatio: _aspectRatio(attributes, snapshot), + child: Image.network( + src, + headers: headers, + width: width ?? _width(attributes) ?? snapshot.data!.width, + height: height ?? _height(attributes), + frameBuilder: (ctx, child, frame, _) { + if (frame == null) { + return altWidget?.call(_alt(attributes)) ?? + Text(_alt(attributes) ?? '', style: context.style.generateTextStyle()); + } + return child; + }, + ), + ), + ); + } else if (snapshot.hasError) { + return altWidget?.call(_alt(context.tree.element!.attributes.cast())) ?? + Text(_alt(context.tree.element!.attributes.cast()) ?? '', style: context.style.generateTextStyle()); + } else { + return loadingWidget?.call() ?? const CircularProgressIndicator(); + } + }, + ); + return Builder( + key: context.key, + builder: (buildContext) { + return GestureDetector( + child: widget, + onTap: () { + if (MultipleTapGestureDetector.of(buildContext) != null) { + MultipleTapGestureDetector.of(buildContext)!.onTap?.call(); + } + context.parser.onImageTap + ?.call(src, context, context.tree.element!.attributes.cast(), context.tree.element); + }, + ); + }); + }); + +CustomRender interactableElementRender({List? children}) => CustomRender.inlineSpan( + inlineSpan: (context, buildChildren) => TextSpan( + children: children ?? + (context.tree as InteractableElement) + .children + .map((tree) => context.parser.parseTree(context, tree)) + .map((childSpan) { + return _getInteractableChildren(context, context.tree as InteractableElement, childSpan, + context.style.generateTextStyle().merge(childSpan.style)); + }).toList(), + )); + +CustomRender layoutElementRender({Widget? child}) => CustomRender.inlineSpan( + inlineSpan: (context, buildChildren) => WidgetSpan( + child: child ?? (context.tree as LayoutElement).toWidget(context)!, + )); + +CustomRender verticalAlignRender({double? verticalOffset, Style? style, List? children}) => + CustomRender.inlineSpan( + inlineSpan: (context, buildChildren) => WidgetSpan( + child: Transform.translate( + key: context.key, + offset: Offset(0, verticalOffset ?? _getVerticalOffset(context.tree)), + child: CssBoxWidget.withInlineSpanChildren( + children: children ?? buildChildren.call(), + style: context.style, + ), + ), + )); + +CustomRender fallbackRender({Style? style, List? children}) => CustomRender.inlineSpan( + inlineSpan: (context, buildChildren) => TextSpan( + style: style?.generateTextStyle() ?? context.style.generateTextStyle(), + children: context.tree.children + .expand((tree) => [ + context.parser.parseTree(context, tree), + if (tree.style.display == Display.block && + tree.element?.parent?.localName != 'th' && + tree.element?.parent?.localName != 'td' && + tree.element?.localName != 'html' && + tree.element?.localName != 'body') + const TextSpan(text: '\n'), + ]) + .toList(), + )); + +Map generateDefaultRenders() { + return { + blockElementMatcher(): blockElementRender(), + listElementMatcher(): listElementRender(), + textContentElementMatcher(): textContentElementRender(), + dataUriMatcher(): base64ImageRender(), + assetUriMatcher(): assetImageRender(), + networkSourceMatcher(): networkImageRender(), + replacedElementMatcher(): replacedElementRender(), + interactableElementMatcher(): interactableElementRender(), + layoutElementMatcher(): layoutElementRender(), + verticalAlignMatcher(): verticalAlignRender(), + fallbackMatcher(): fallbackRender(), + }; +} + +InlineSpan _getInteractableChildren( + RenderContext context, InteractableElement tree, InlineSpan childSpan, TextStyle childStyle) { + if (childSpan is TextSpan) { + return TextSpan( + text: childSpan.text, + children: childSpan.children + ?.map((e) => _getInteractableChildren(context, tree, e, childStyle.merge(childSpan.style))) + .toList(), + style: context.style + .generateTextStyle() + .merge(childSpan.style == null ? childStyle : childStyle.merge(childSpan.style)), + semanticsLabel: childSpan.semanticsLabel, + recognizer: TapGestureRecognizer() + ..onTap = context.parser.internalOnAnchorTap != null + ? () => context.parser.internalOnAnchorTap!(tree.href, context, tree.attributes, tree.element) + : null, + ); + } else { + return WidgetSpan( + child: MultipleTapGestureDetector( + onTap: context.parser.internalOnAnchorTap != null + ? () => context.parser.internalOnAnchorTap!(tree.href, context, tree.attributes, tree.element) + : null, + child: GestureDetector( + key: context.key, + onTap: context.parser.internalOnAnchorTap != null + ? () => context.parser.internalOnAnchorTap!(tree.href, context, tree.attributes, tree.element) + : null, + child: (childSpan as WidgetSpan).child, + ), + ), + ); + } +} + +final _dataUriFormat = RegExp('^(?data):(?image\\/[\\w\\+\\-\\.]+)(?;base64)?\\,(?.*)'); + +double _getVerticalOffset(StyledElement tree) { + switch (tree.style.verticalAlign) { + case VerticalAlign.sub: + return tree.style.fontSize!.value / 2.5; + case VerticalAlign.sup: + return tree.style.fontSize!.value / -2.5; + default: + return 0; + } +} + +String? _src(Map attributes) { + return attributes['src']; +} + +String? _alt(Map attributes) { + return attributes['alt']; +} + +double? _height(Map attributes) { + final heightString = attributes['height']; + return heightString == null ? heightString as double? : double.tryParse(heightString); +} + +double? _width(Map attributes) { + final widthString = attributes['width']; + return widthString == null ? widthString as double? : double.tryParse(widthString); +} + +double _aspectRatio(Map attributes, AsyncSnapshot calculated) { + final heightString = attributes['height']; + final widthString = attributes['width']; + if (heightString != null && widthString != null) { + final height = double.tryParse(heightString); + final width = double.tryParse(widthString); + return height == null || width == null ? calculated.data!.aspectRatio : width / height; + } + return calculated.data!.aspectRatio; +} + +extension ClampedEdgeInsets on EdgeInsetsGeometry { + EdgeInsetsGeometry get nonNegative => clamp(EdgeInsets.zero, const EdgeInsets.all(double.infinity)); +} diff --git a/lib/core/html/flutter_html.dart b/lib/core/html/flutter_html.dart new file mode 100644 index 0000000..38507d3 --- /dev/null +++ b/lib/core/html/flutter_html.dart @@ -0,0 +1,326 @@ +library flutter_html; + +import 'package:flutter/material.dart'; +import 'package:html/dom.dart' as dom; +import 'package:sonnat/core/html/custom_render.dart'; +import 'package:sonnat/core/html/html_parser.dart'; +import 'package:sonnat/core/html/src/html_elements.dart'; +import 'package:sonnat/core/html/style.dart'; + +class Html extends StatefulWidget { + Html({ + super.key, + GlobalKey? anchorKey, + required this.data, + this.onLinkTap, + this.onAnchorTap, + this.customRenders = const {}, + this.onCssParseError, + this.onImageError, + this.shrinkWrap = false, + this.onImageTap, + this.tagsList = const [], + this.style = const {}, + }) : documentElement = null, + assert(data != null), + _anchorKey = anchorKey ?? GlobalKey(); + + Html.fromDom({ + super.key, + GlobalKey? anchorKey, + @required dom.Document? document, + this.onLinkTap, + this.onAnchorTap, + this.customRenders = const {}, + this.onCssParseError, + this.onImageError, + this.shrinkWrap = false, + this.onImageTap, + this.tagsList = const [], + this.style = const {}, + }) : data = null, + assert(document != null), + documentElement = document!.documentElement, + _anchorKey = anchorKey ?? GlobalKey(); + + Html.fromElement({ + super.key, + GlobalKey? anchorKey, + @required this.documentElement, + this.onLinkTap, + this.onAnchorTap, + this.customRenders = const {}, + this.onCssParseError, + this.onImageError, + this.shrinkWrap = false, + this.onImageTap, + this.tagsList = const [], + this.style = const {}, + }) : data = null, + assert(documentElement != null), + _anchorKey = anchorKey ?? GlobalKey(); + + /// A unique key for this Html widget to ensure uniqueness of anchors + final GlobalKey _anchorKey; + + /// The HTML data passed to the widget as a String + final String? data; + + /// The HTML data passed to the widget as a pre-processed [dom.Element] + final dom.Element? documentElement; + + /// A function that defines what to do when a link is tapped + final OnTap? onLinkTap; + + /// A function that defines what to do when an anchor link is tapped. When this value is set, + /// the default anchor behaviour is overwritten. + final OnTap? onAnchorTap; + + /// A function that defines what to do when CSS fails to parse + final OnCssParseError? onCssParseError; + + /// A function that defines what to do when an image errors + final ImageErrorListener? onImageError; + + /// A parameter that should be set when the HTML widget is expected to be + /// flexible + final bool shrinkWrap; + + /// A function that defines what to do when an image is tapped + final OnTap? onImageTap; + + /// A list of HTML tags that are the only tags that are rendered. By default, this list is empty and all supported HTML tags are rendered. + final List tagsList; + + /// Either return a custom widget for specific node types or return null to + /// fallback to the default rendering. + final Map customRenders; + + /// An API that allows you to override the default style for any HTML element + final Map style; + + static List get tags => List.from(HtmlElements.styledElements) + ..addAll(HtmlElements.interactableElements) + ..addAll(HtmlElements.replacedElements) + ..addAll(HtmlElements.layoutElements) + ..addAll(HtmlElements.tableCellElements) + ..addAll(HtmlElements.tableDefinitionElements) + ..addAll(HtmlElements.externalElements); + + @override + State createState() => _HtmlState(); +} + +class _HtmlState extends State { + late dom.Element documentElement; + + @override + void initState() { + super.initState(); + documentElement = widget.data != null + ? HtmlParser.parseHTML(widget.data!) + : widget.documentElement!; + } + + @override + void didUpdateWidget(Html oldWidget) { + super.didUpdateWidget(oldWidget); + if ((widget.data != null && oldWidget.data != widget.data) || + oldWidget.documentElement != widget.documentElement) { + documentElement = widget.data != null + ? HtmlParser.parseHTML(widget.data!) + : widget.documentElement!; + } + } + + @override + Widget build(BuildContext context) { + return HtmlParser( + key: widget._anchorKey, + htmlData: documentElement, + onLinkTap: widget.onLinkTap, + onAnchorTap: widget.onAnchorTap, + onImageTap: widget.onImageTap, + onCssParseError: widget.onCssParseError, + onImageError: widget.onImageError, + shrinkWrap: widget.shrinkWrap, + selectable: false, + style: widget.style, + customRenders: {} + ..addAll(widget.customRenders) + ..addAll(generateDefaultRenders()), + tagsList: widget.tagsList.isEmpty ? Html.tags : widget.tagsList, + ); + } +} + +class SelectableHtml extends StatefulWidget { + /// The `SelectableHtml` widget takes HTML as input and displays a RichText + /// tree of the parsed HTML content (which is selectable) + /// + /// **Attributes** + /// **data** *required* takes in a String of HTML data (required only for `Html` constructor). + /// **documentElement** *required* takes in a Element of HTML data (required only for `Html.fromDom` and `Html.fromElement` constructor). + /// + /// **onLinkTap** This function is called whenever a link (``) + /// is tapped. + /// + /// **onAnchorTap** This function is called whenever an anchor (#anchor-id) + /// is tapped. + /// + /// **tagsList** Tag names in this array will be the only tags rendered. By default, all tags that support selectable content are rendered. + /// + /// **style** Pass in the style information for the Html here. + /// See [its wiki page](https://github.com/Sub6Resources/flutter_html/wiki/Style) for more info. + /// + /// **PLEASE NOTE** + /// + /// There are a few caveats due to Flutter [#38474](https://github.com/flutter/flutter/issues/38474): + /// + /// 1. The list of tags that can be rendered is significantly reduced. + /// Key omissions include no support for images/video/audio, table, and ul/ol because they all require widgets and `WidgetSpan`s. + /// + /// 2. No support for `customRender`, `customImageRender`, `onImageError`, `onImageTap`, `onMathError`, and `navigationDelegateForIframe`. + /// + /// 3. Styling support is significantly reduced. Only text-related styling works + /// (e.g. bold or italic), while container related styling (e.g. borders or padding/margin) + /// do not work because we can't use the `ContainerSpan` class (it needs an enclosing `WidgetSpan`). + + SelectableHtml({ + super.key, + GlobalKey? anchorKey, + required this.data, + this.onLinkTap, + this.onAnchorTap, + this.onCssParseError, + this.shrinkWrap = false, + this.style = const {}, + this.customRenders = const {}, + this.tagsList = const [], + this.selectionControls, + this.scrollPhysics, + }) : documentElement = null, + assert(data != null), + _anchorKey = anchorKey ?? GlobalKey(); + + SelectableHtml.fromDom({ + super.key, + GlobalKey? anchorKey, + @required dom.Document? document, + this.onLinkTap, + this.onAnchorTap, + this.onCssParseError, + this.shrinkWrap = false, + this.style = const {}, + this.customRenders = const {}, + this.tagsList = const [], + this.selectionControls, + this.scrollPhysics, + }) : data = null, + assert(document != null), + documentElement = document!.documentElement, + _anchorKey = anchorKey ?? GlobalKey(); + + SelectableHtml.fromElement({ + super.key, + GlobalKey? anchorKey, + @required this.documentElement, + this.onLinkTap, + this.onAnchorTap, + this.onCssParseError, + this.shrinkWrap = false, + this.style = const {}, + this.customRenders = const {}, + this.tagsList = const [], + this.selectionControls, + this.scrollPhysics, + }) : data = null, + assert(documentElement != null), + _anchorKey = anchorKey ?? GlobalKey(); + + /// A unique key for this Html widget to ensure uniqueness of anchors + final GlobalKey _anchorKey; + + /// The HTML data passed to the widget as a String + final String? data; + + /// The HTML data passed to the widget as a pre-processed [dom.Element] + final dom.Element? documentElement; + + /// A function that defines what to do when a link is tapped + final OnTap? onLinkTap; + + /// A function that defines what to do when an anchor link is tapped. When this value is set, + /// the default anchor behaviour is overwritten. + final OnTap? onAnchorTap; + + /// A function that defines what to do when CSS fails to parse + final OnCssParseError? onCssParseError; + + /// A parameter that should be set when the HTML widget is expected to be + /// have a flexible width, that doesn't always fill its maximum width + /// constraints. For example, auto horizontal margins are ignored, and + /// block-level elements only take up the width they need. + final bool shrinkWrap; + + /// A list of HTML tags that are the only tags that are rendered. By default, this list is empty and all supported HTML tags are rendered. + final List tagsList; + + /// An API that allows you to override the default style for any HTML element + final Map style; + + /// Custom Selection controls allows you to override default toolbar and build custom toolbar + /// options + final TextSelectionControls? selectionControls; + + /// Allows you to override the default scrollPhysics for [SelectableText.rich] + final ScrollPhysics? scrollPhysics; + + /// Either return a custom widget for specific node types or return null to + /// fallback to the default rendering. + final Map customRenders; + + static List get tags => + List.from(HtmlElements.selectableElements); + + @override + State createState() => _SelectableHtmlState(); +} + +class _SelectableHtmlState extends State { + late final dom.Element documentElement; + + @override + void initState() { + super.initState(); + documentElement = widget.data != null + ? HtmlParser.parseHTML(widget.data!) + : widget.documentElement!; + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.shrinkWrap ? null : MediaQuery.of(context).size.width, + child: HtmlParser( + key: widget._anchorKey, + htmlData: documentElement, + onLinkTap: widget.onLinkTap, + onAnchorTap: widget.onAnchorTap, + onImageTap: null, + onCssParseError: widget.onCssParseError, + onImageError: null, + shrinkWrap: widget.shrinkWrap, + selectable: true, + style: widget.style, + customRenders: {} + ..addAll(widget.customRenders) + ..addAll(generateDefaultRenders()), + tagsList: + widget.tagsList.isEmpty ? SelectableHtml.tags : widget.tagsList, + selectionControls: widget.selectionControls, + scrollPhysics: widget.scrollPhysics, + ), + ); + } +} diff --git a/lib/core/html/html_parser.dart b/lib/core/html/html_parser.dart new file mode 100644 index 0000000..0332bbe --- /dev/null +++ b/lib/core/html/html_parser.dart @@ -0,0 +1,877 @@ +import 'dart:collection'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:csslib/parser.dart' as cssparser; +import 'package:csslib/visitor.dart' as css; +import 'package:flutter/material.dart'; +import 'package:html/dom.dart' as dom; +import 'package:html/parser.dart' as htmlparser; +import 'package:list_counter/list_counter.dart'; +import 'package:sonnat/core/html/custom_render.dart'; +import 'package:sonnat/core/html/flutter_html.dart'; +import 'package:sonnat/core/html/src/anchor.dart'; +import 'package:sonnat/core/html/src/css_box_widget.dart'; +import 'package:sonnat/core/html/src/css_parser.dart'; +import 'package:sonnat/core/html/src/html_elements.dart'; +import 'package:sonnat/core/html/src/layout_element.dart'; +import 'package:sonnat/core/html/src/style/fontsize.dart'; +import 'package:sonnat/core/html/src/style/length.dart'; +import 'package:sonnat/core/html/src/style/margin.dart'; +import 'package:sonnat/core/html/src/style/marker.dart'; +import 'package:sonnat/core/html/src/utils.dart'; +import 'package:sonnat/core/html/style.dart'; + +typedef OnTap = void Function( + String? url, + RenderContext context, + Map attributes, + dom.Element? element, +); +typedef OnCssParseError = String? Function( + String css, + List errors, +); + +class HtmlParser extends StatelessWidget { + final dom.Element htmlData; + final OnTap? onLinkTap; + final OnTap? onAnchorTap; + final OnTap? onImageTap; + final OnCssParseError? onCssParseError; + final ImageErrorListener? onImageError; + final bool shrinkWrap; + final bool selectable; + + final Map style; + final Map customRenders; + final List tagsList; + final OnTap? internalOnAnchorTap; + final Html? root; + final TextSelectionControls? selectionControls; + final ScrollPhysics? scrollPhysics; + + final Map cachedImageSizes = {}; + + HtmlParser({ + required super.key, + required this.htmlData, + required this.onLinkTap, + required this.onAnchorTap, + required this.onImageTap, + required this.onCssParseError, + required this.onImageError, + required this.shrinkWrap, + required this.selectable, + required this.style, + required this.customRenders, + required this.tagsList, + this.root, + this.selectionControls, + this.scrollPhysics, + }) : internalOnAnchorTap = onAnchorTap ?? (key != null ? _handleAnchorTap(key, onLinkTap) : onLinkTap); + + @override + Widget build(BuildContext context) { + // Lexing Step + StyledElement lexedTree = lexDomTree( + htmlData, + customRenders.keys.toList(), + tagsList, + context, + this, + ); + + // Styling Step + StyledElement styledTree = styleTree(lexedTree, htmlData, style, onCssParseError); + + // Processing Step + StyledElement processedTree = processTree(styledTree, MediaQuery.of(context).devicePixelRatio); + + // Parsing Step + InlineSpan parsedTree = parseTree( + RenderContext( + buildContext: context, + parser: this, + tree: processedTree, + style: processedTree.style, + ), + processedTree, + ); + + return CssBoxWidget.withInlineSpanChildren( + style: processedTree.style, + children: [parsedTree], + selectable: selectable, + scrollPhysics: scrollPhysics, + selectionControls: selectionControls, + shrinkWrap: shrinkWrap, + ); + } + + /// [parseHTML] converts a string of HTML to a DOM element using the dart `html` library. + static dom.Element parseHTML(String data) { + return htmlparser.parse(data).documentElement!; + } + + /// [parseCss] converts a string of CSS to a CSS stylesheet using the dart `csslib` library. + static css.StyleSheet parseCss(String data) { + return cssparser.parse(data); + } + + /// [lexDomTree] converts a DOM document to a simplified tree of [StyledElement]s. + static StyledElement lexDomTree( + dom.Element html, + List customRenderMatchers, + List tagsList, + BuildContext context, + HtmlParser parser, + ) { + StyledElement tree = StyledElement( + name: '[Tree Root]', + children: [], + node: html, + style: Style.fromTextStyle(Theme.of(context).textTheme.bodyMedium!), + ); + + for (var node in html.nodes) { + tree.children.add(_recursiveLexer( + node, + customRenderMatchers, + tagsList, + context, + parser, + )); + } + + return tree; + } + + /// [_recursiveLexer] is the recursive worker function for [lexDomTree]. + /// + /// It runs the parse functions of every type of + /// element and returns a [StyledElement] tree representing the element. + static StyledElement _recursiveLexer( + dom.Node node, + List customRenderMatchers, + List tagsList, + BuildContext context, + HtmlParser parser, + ) { + List children = []; + + for (var childNode in node.nodes) { + children.add(_recursiveLexer( + childNode, + customRenderMatchers, + tagsList, + context, + parser, + )); + } + if (node is dom.Element) { + if (!tagsList.contains(node.localName)) { + return EmptyContentElement(); + } + if (HtmlElements.styledElements.contains(node.localName)) { + return parseStyledElement(node, children); + } + if (HtmlElements.interactableElements.contains(node.localName)) { + return parseInteractableElement(node, children); + } + if (HtmlElements.replacedElements.contains(node.localName)) { + return parseReplacedElement(node, children); + } + if (HtmlElements.layoutElements.contains(node.localName)) { + return parseLayoutElement(node, children); + } + if (HtmlElements.tableCellElements.contains(node.localName)) { + return parseTableCellElement(node, children); + } + if (HtmlElements.tableDefinitionElements.contains(node.localName)) { + return parseTableDefinitionElement(node, children); + } else { + final StyledElement tree = parseStyledElement(node, children); + for (final entry in customRenderMatchers) { + if (entry.call( + RenderContext( + buildContext: context, + parser: parser, + tree: tree, + style: Style.fromTextStyle(Theme.of(context).textTheme.bodyMedium!), + ), + )) { + return tree; + } + } + return EmptyContentElement(); + } + } else if (node is dom.Text) { + return TextContentElement( + text: node.text, + style: Style(), + element: node.parent, + node: node, + ); + } else { + return EmptyContentElement(); + } + } + + static Map>> _getExternalCssDeclarations( + List styles, OnCssParseError? errorHandler) { + String fullCss = ''; + for (final e in styles) { + fullCss = fullCss + e.innerHtml; + } + if (fullCss.isNotEmpty) { + final declarations = parseExternalCss(fullCss, errorHandler); + return declarations; + } else { + return {}; + } + } + + static StyledElement _applyExternalCss( + Map>> declarations, StyledElement tree) { + declarations.forEach((key, style) { + try { + if (tree.matchesSelector(key)) { + tree.style = tree.style.merge(declarationsToStyle(style)); + } + } catch (_) {} + }); + + for (var element in tree.children) { + _applyExternalCss(declarations, element); + } + + return tree; + } + + static StyledElement _applyInlineStyles(StyledElement tree, OnCssParseError? errorHandler) { + if (tree.attributes.containsKey('style')) { + final newStyle = inlineCssToStyle(tree.attributes['style'], errorHandler); + if (newStyle != null) { + tree.style = tree.style.merge(newStyle); + } + } + + for (var element in tree.children) { + _applyInlineStyles(element, errorHandler); + } + return tree; + } + + /// [applyCustomStyles] applies the [Style] objects passed into the [Html] + /// widget onto the [StyledElement] tree, no cascading of styles is done at this point. + static StyledElement _applyCustomStyles(Map style, StyledElement tree) { + style.forEach((key, style) { + try { + if (tree.matchesSelector(key)) { + tree.style = tree.style.merge(style); + } + } catch (_) {} + }); + for (var element in tree.children) { + _applyCustomStyles(style, element); + } + + return tree; + } + + /// [_cascadeStyles] cascades all of the inherited styles down the tree, applying them to each + /// child that doesn't specify a different style. + static StyledElement _cascadeStyles(Map style, StyledElement tree) { + for (var child in tree.children) { + child.style = tree.style.copyOnlyInherited(child.style); + _cascadeStyles(style, child); + } + + return tree; + } + + /// [styleTree] takes the lexed [StyleElement] tree and applies external, + /// inline, and custom CSS/Flutter styles, and then cascades the styles down the tree. + static StyledElement styleTree( + StyledElement tree, dom.Element htmlData, Map style, OnCssParseError? onCssParseError) { + Map>> declarations = + _getExternalCssDeclarations(htmlData.getElementsByTagName('style'), onCssParseError); + + StyledElement? externalCssStyledTree; + if (declarations.isNotEmpty) { + externalCssStyledTree = _applyExternalCss(declarations, tree); + } + tree = _applyInlineStyles(externalCssStyledTree ?? tree, onCssParseError); + tree = _applyCustomStyles(style, tree); + tree = _cascadeStyles(style, tree); + return tree; + } + + /// [processTree] optimizes the [StyledElement] tree so all [BlockElement]s are + /// on the first level, redundant levels are collapsed, empty elements are + /// removed, and specialty elements are processed. + static StyledElement processTree(StyledElement tree, double devicePixelRatio) { + tree = _processInternalWhitespace(tree); + tree = _processInlineWhitespace(tree); + tree = _removeEmptyElements(tree); + + tree = _calculateRelativeValues(tree, devicePixelRatio); + tree = _preprocessListMarkers(tree); + tree = _processCounters(tree); + tree = _processListMarkers(tree); + tree = _processBeforesAndAfters(tree); + tree = _collapseMargins(tree); + return tree; + } + + /// [parseTree] converts a tree of [StyledElement]s to an [InlineSpan] tree. + /// + /// [parseTree] is responsible for handling the [customRenders] parameter and + /// deciding what different `Style.display` options look like as Widgets. + InlineSpan parseTree(RenderContext context, StyledElement tree) { + // Merge this element's style into the context so that children + // inherit the correct style + RenderContext newContext = RenderContext( + buildContext: context.buildContext, + parser: this, + tree: tree, + style: context.style.copyOnlyInherited(tree.style), + key: AnchorKey.of(key, tree), + ); + + for (final entry in customRenders.keys) { + if (entry.call(newContext)) { + List buildChildren() => tree.children.map((tree) => parseTree(newContext, tree)).toList(); + if (newContext.parser.selectable && customRenders[entry] is SelectableCustomRender) { + List selectableBuildChildren() => + tree.children.map((tree) => parseTree(newContext, tree) as TextSpan).toList(); + return (customRenders[entry] as SelectableCustomRender).textSpan.call(newContext, selectableBuildChildren); + } + if (newContext.parser.selectable) { + return customRenders[entry]!.inlineSpan!.call(newContext, buildChildren) as TextSpan; + } + if (customRenders[entry]?.inlineSpan != null) { + return customRenders[entry]!.inlineSpan!.call(newContext, buildChildren); + } + return WidgetSpan( + child: CssBoxWidget( + style: tree.style, + shrinkWrap: newContext.parser.shrinkWrap, + childIsReplaced: true, + child: customRenders[entry]!.widget!.call(newContext, buildChildren), + ), + ); + } + } + return const WidgetSpan(child: SizedBox(height: 0, width: 0)); + } + + static OnTap _handleAnchorTap(Key key, OnTap? onLinkTap) => (url, context, attributes, element) { + if (url?.startsWith('#') == true) { + final anchorContext = AnchorKey.forId(key, url!.substring(1))?.currentContext; + if (anchorContext != null) { + Scrollable.ensureVisible(anchorContext); + } + return; + } + onLinkTap?.call(url, context, attributes, element); + }; + + /// [processWhitespace] removes unnecessary whitespace from the StyledElement tree. + /// + /// The criteria for determining which whitespace is replaceable is outlined + /// at https://www.w3.org/TR/css-text-3/ + /// and summarized at https://medium.com/@patrickbrosset/when-does-white-space-matter-in-html-b90e8a7cdd33 + static StyledElement _processInternalWhitespace(StyledElement tree) { + if ((tree.style.whiteSpace ?? WhiteSpace.normal) == WhiteSpace.pre) { + // Preserve this whitespace + } else if (tree is TextContentElement) { + tree.text = _removeUnnecessaryWhitespace(tree.text!); + } else { + tree.children.forEach(_processInternalWhitespace); + } + return tree; + } + + /// [_processInlineWhitespace] is responsible for removing redundant whitespace + /// between and among inline elements. It does so by creating a boolean [Context] + /// and passing it to the [_processInlineWhitespaceRecursive] function. + static StyledElement _processInlineWhitespace(StyledElement tree) { + tree = _processInlineWhitespaceRecursive(tree, Context(false)); + return tree; + } + + /// [_processInlineWhitespaceRecursive] analyzes the whitespace between and among different + /// inline elements, and replaces any instance of two or more spaces with a single space, according + /// to the w3's HTML whitespace processing specification linked to above. + static StyledElement _processInlineWhitespaceRecursive( + StyledElement tree, + Context keepLeadingSpace, + ) { + if (tree is TextContentElement) { + /// initialize indices to negative numbers to make conditionals a little easier + int textIndex = -1; + int elementIndex = -1; + + /// initialize parent after to a whitespace to account for elements that are + /// the last child in the list of elements + String parentAfterText = ' '; + + /// find the index of the text in the current tree + if ((tree.element?.nodes.length ?? 0) >= 1) { + textIndex = tree.element?.nodes.indexWhere((element) => element == tree.node) ?? -1; + } + + /// get the parent nodes + dom.NodeList? parentNodes = tree.element?.parent?.nodes; + + /// find the index of the tree itself in the parent nodes + if ((parentNodes?.length ?? 0) >= 1) { + elementIndex = parentNodes?.indexWhere((element) => element == tree.element) ?? -1; + } + + /// if the tree is any node except the last node in the node list and the + /// next node in the node list is a text node, then get its text. Otherwise + /// the next node will be a [dom.Element], so keep unwrapping that until + /// we get the underlying text node, and finally get its text. + if (elementIndex < (parentNodes?.length ?? 1) - 1 && parentNodes?[elementIndex + 1] is dom.Text) { + parentAfterText = parentNodes?[elementIndex + 1].text ?? ' '; + } else if (elementIndex < (parentNodes?.length ?? 1) - 1) { + var parentAfter = parentNodes?[elementIndex + 1]; + while (parentAfter is dom.Element) { + if (parentAfter.nodes.isNotEmpty) { + parentAfter = parentAfter.nodes.first; + } else { + break; + } + } + parentAfterText = parentAfter?.text ?? ' '; + } + + /// If the text is the first element in the current tree node list, it + /// starts with a whitespace, it isn't a line break, either the + /// whitespace is unnecessary or it is a block element, and either it is + /// first element in the parent node list or the previous element + /// in the parent node list ends with a whitespace, delete it. + /// + /// We should also delete the whitespace at any point in the node list + /// if the previous element is a
because that tag makes the element + /// act like a block element. + if (textIndex < 1 && + tree.text!.startsWith(' ') && + tree.element?.localName != 'br' && + (!keepLeadingSpace.data || tree.style.display == Display.block) && + (elementIndex < 1 || + (elementIndex >= 1 && + parentNodes?[elementIndex - 1] is dom.Text && + parentNodes![elementIndex - 1].text!.endsWith(' ')))) { + tree.text = tree.text!.replaceFirst(' ', ''); + } else if (textIndex >= 1 && + tree.text!.startsWith(' ') && + tree.element?.nodes[textIndex - 1] is dom.Element && + (tree.element?.nodes[textIndex - 1] as dom.Element).localName == 'br') { + tree.text = tree.text!.replaceFirst(' ', ''); + } + + /// If the text is the last element in the current tree node list, it isn't + /// a line break, and the next text node starts with a whitespace, + /// update the [Context] to signify to that next text node whether it should + /// keep its whitespace. This is based on whether the current text ends with a + /// whitespace. + if (textIndex == (tree.element?.nodes.length ?? 1) - 1 && + tree.element?.localName != 'br' && + parentAfterText.startsWith(' ')) { + keepLeadingSpace.data = !tree.text!.endsWith(' '); + } + } + + for (var element in tree.children) { + _processInlineWhitespaceRecursive(element, keepLeadingSpace); + } + + return tree; + } + + /// [removeUnnecessaryWhitespace] removes "unnecessary" white space from the given String. + /// + /// The steps for removing this whitespace are as follows: + /// (1) Remove any whitespace immediately preceding or following a newline. + /// (2) Replace all newlines with a space + /// (3) Replace all tabs with a space + /// (4) Replace any instances of two or more spaces with a single space. + static String _removeUnnecessaryWhitespace(String text) { + return text + .replaceAll(RegExp('\\ *(?=\n)'), '\n') + .replaceAll(RegExp('(?:\n)\\ *'), '\n') + .replaceAll('\n', ' ') + .replaceAll('\t', ' ') + .replaceAll(RegExp(' {2,}'), ' '); + } + + /// [preprocessListMarkers] adds marker pseudo elements to the front of all list + /// items. + static StyledElement _preprocessListMarkers(StyledElement tree) { + tree.style.listStylePosition ??= ListStylePosition.outside; + + if (tree.style.display == Display.listItem) { + // Add the marker pseudo-element if it doesn't exist + tree.style.marker ??= Marker( + content: Content.normal, + style: tree.style, + ); + + // Inherit styles from originating widget + tree.style.marker!.style = tree.style.copyOnlyInherited(tree.style.marker!.style ?? Style()); + + // Add the implicit counter-increment on `list-item` if it isn't set + // explicitly already + tree.style.counterIncrement ??= {}; + if (!tree.style.counterIncrement!.containsKey('list-item')) { + tree.style.counterIncrement!['list-item'] = 1; + } + } + + // Add the counters to ol and ul types. + if (tree.name == 'ol' || tree.name == 'ul') { + tree.style.counterReset ??= {}; + if (!tree.style.counterReset!.containsKey('list-item')) { + tree.style.counterReset!['list-item'] = 0; + } + } + + for (var child in tree.children) { + _preprocessListMarkers(child); + } + + return tree; + } + + /// [_processListCounters] adds the appropriate counter values to each + /// StyledElement on the tree. + static StyledElement _processCounters(StyledElement tree, [ListQueue? counters]) { + // Add the counters for the current scope. + tree.counters.addAll(counters?.deepCopy() ?? []); + + // Create any new counters + if (tree.style.counterReset != null) { + tree.style.counterReset!.forEach((counterName, initialValue) { + tree.counters.add(Counter(counterName, initialValue ?? 0)); + }); + } + + // Increment any counters that are to be incremented + if (tree.style.counterIncrement != null) { + tree.style.counterIncrement!.forEach((counterName, increment) { + tree.counters + .lastWhereOrNull( + (counter) => counter.name == counterName, + ) + ?.increment(increment ?? 1); + + // If we didn't newly create the counter, increment the counter in the old copy as well. + if (tree.style.counterReset == null || !tree.style.counterReset!.containsKey(counterName)) { + counters + ?.lastWhereOrNull( + (counter) => counter.name == counterName, + ) + ?.increment(increment ?? 1); + } + }); + } + + for (var element in tree.children) { + _processCounters(element, tree.counters); + } + + return tree; + } + + static StyledElement _processListMarkers(StyledElement tree) { + if (tree.style.display == Display.listItem) { + final listStyleType = tree.style.listStyleType ?? ListStyleType.decimal; + final counterStyle = CounterStyleRegistry.lookup( + listStyleType.counterStyle, + ); + String counterContent; + if (tree.style.marker?.content.isNormal ?? true) { + counterContent = counterStyle.generateMarkerContent( + tree.counters.lastOrNull?.value ?? 0, + ); + } else if (!(tree.style.marker?.content.display ?? true)) { + counterContent = ''; + } else { + counterContent = tree.style.marker?.content.replacementContent ?? + counterStyle.generateMarkerContent( + tree.counters.lastOrNull?.value ?? 0, + ); + } + tree.style.marker = Marker(content: Content(counterContent), style: tree.style.marker?.style); + } + + for (var child in tree.children) { + _processListMarkers(child); + } + + return tree; + } + + /// [_processBeforesAndAfters] adds text content to the beginning and end of + /// the list of the trees children according to the `before` and `after` Style + /// properties. + static StyledElement _processBeforesAndAfters(StyledElement tree) { + if (tree.style.before != null) { + tree.children.insert( + 0, + TextContentElement( + text: tree.style.before, + style: tree.style.copyWith(beforeAfterNull: true, display: Display.inline), + ), + ); + } + if (tree.style.after != null) { + tree.children.add(TextContentElement( + text: tree.style.after, + style: tree.style.copyWith(beforeAfterNull: true, display: Display.inline), + )); + } + + tree.children.forEach(_processBeforesAndAfters); + + return tree; + } + + /// [collapseMargins] follows the specifications at https://www.w3.org/TR/CSS22/box.html#collapsing-margins + /// for collapsing margins of block-level boxes. This prevents the doubling of margins between + /// boxes, and makes for a more correct rendering of the html content. + /// + /// Paraphrased from the CSS specification: + /// Margins are collapsed if both belong to vertically-adjacent box edges, i.e form one of the following pairs: + /// (1) Top margin of a box and top margin of its first in-flow child + /// (2) Bottom margin of a box and top margin of its next in-flow following sibling + /// (3) Bottom margin of a last in-flow child and bottom margin of its parent (if the parent's height is not explicit) + /// (4) Top and Bottom margins of a box with a height of zero or no in-flow children. + static StyledElement _collapseMargins(StyledElement tree) { + //Short circuit if we've reached a leaf of the tree + if (tree.children.isEmpty) { + // Handle case (4) from above. + if (tree.style.height?.value == 0 && tree.style.height?.unit != Unit.auto) { + tree.style.margin = tree.style.margin?.collapse() ?? Margins.zero; + } + return tree; + } + + //Collapsing should be depth-first. + tree.children.forEach(_collapseMargins); + + //The root boxes do not collapse. + if (tree.name == '[Tree Root]' || tree.name == 'html') { + return tree; + } + + // Handle case (1) from above. + // Top margins cannot collapse if the element has padding + if ((tree.style.padding?.top ?? 0) == 0) { + final parentTop = tree.style.margin?.top?.value ?? 0; + final firstChildTop = tree.children.first.style.margin?.top?.value ?? 0; + final newOuterMarginTop = max(parentTop, firstChildTop); + + // Set the parent's margin + if (tree.style.margin == null) { + tree.style.margin = Margins.only(top: newOuterMarginTop); + } else { + tree.style.margin = tree.style.margin!.copyWithEdge(top: newOuterMarginTop); + } + + // And remove the child's margin + if (tree.children.first.style.margin == null) { + tree.children.first.style.margin = Margins.zero; + } else { + tree.children.first.style.margin = tree.children.first.style.margin!.copyWithEdge(top: 0); + } + } + + // Handle case (3) from above. + // Bottom margins cannot collapse if the element has padding + if ((tree.style.padding?.bottom ?? 0) == 0) { + final parentBottom = tree.style.margin?.bottom?.value ?? 0; + final lastChildBottom = tree.children.last.style.margin?.bottom?.value ?? 0; + final newOuterMarginBottom = max(parentBottom, lastChildBottom); + + // Set the parent's margin + if (tree.style.margin == null) { + tree.style.margin = Margins.only(bottom: newOuterMarginBottom); + } else { + tree.style.margin = tree.style.margin!.copyWithEdge(bottom: newOuterMarginBottom); + } + + // And remove the child's margin + if (tree.children.last.style.margin == null) { + tree.children.last.style.margin = Margins.zero; + } else { + tree.children.last.style.margin = tree.children.last.style.margin!.copyWithEdge(bottom: 0); + } + } + + // Handle case (2) from above. + if (tree.children.length > 1) { + for (int i = 1; i < tree.children.length; i++) { + final previousSiblingBottom = tree.children[i - 1].style.margin?.bottom?.value ?? 0; + final thisTop = tree.children[i].style.margin?.top?.value ?? 0; + final newInternalMargin = max(previousSiblingBottom, thisTop); + + if (tree.children[i - 1].style.margin == null) { + tree.children[i - 1].style.margin = Margins.only(bottom: newInternalMargin); + } else { + tree.children[i - 1].style.margin = + tree.children[i - 1].style.margin!.copyWithEdge(bottom: newInternalMargin); + } + + if (tree.children[i].style.margin == null) { + tree.children[i].style.margin = Margins.only(top: newInternalMargin); + } else { + tree.children[i].style.margin = tree.children[i].style.margin!.copyWithEdge(top: newInternalMargin); + } + } + } + + return tree; + } + + /// [removeEmptyElements] recursively removes empty elements. + /// + /// An empty element is any [EmptyContentElement], any empty [TextContentElement], + /// or any block-level [TextContentElement] that contains only whitespace and doesn't follow + /// a block element or a line break. + static StyledElement _removeEmptyElements(StyledElement tree) { + List toRemove = []; + bool lastChildBlock = true; + tree.children.forEachIndexed((index, child) { + if (child is EmptyContentElement || child is EmptyLayoutElement) { + toRemove.add(child); + } else if (child is TextContentElement && + ((tree.name == 'body' && + (index == 0 || + index + 1 == tree.children.length || + tree.children[index - 1].style.display == Display.block || + tree.children[index + 1].style.display == Display.block)) || + tree.name == 'ul') && + child.text!.replaceAll(' ', '').isEmpty) { + toRemove.add(child); + } else if (child is TextContentElement && child.text!.isEmpty && child.style.whiteSpace != WhiteSpace.pre) { + toRemove.add(child); + } else if (child is TextContentElement && + child.style.whiteSpace != WhiteSpace.pre && + tree.style.display == Display.block && + child.text!.isEmpty && + lastChildBlock) { + toRemove.add(child); + } else if (child.style.display == Display.none) { + toRemove.add(child); + } else { + _removeEmptyElements(child); + } + + // This is used above to check if the previous element is a block element or a line break. + lastChildBlock = (child.style.display == Display.block || + child.style.display == Display.listItem || + (child is TextContentElement && child.text == '\n')); + }); + tree.children.removeWhere((element) => toRemove.contains(element)); + + return tree; + } + + /// [_calculateRelativeValues] converts rem values to px sizes and then + /// applies relative calculations + static StyledElement _calculateRelativeValues(StyledElement tree, double devicePixelRatio) { + double remSize = (tree.style.fontSize?.value ?? FontSize.medium.value); + + //If the root element has a rem-based fontSize, then give it the default + // font size times the set rem value. + if (tree.style.fontSize?.unit == Unit.rem) { + tree.style.fontSize = FontSize(FontSize.medium.value * remSize); + } + + _applyRelativeValuesRecursive(tree, remSize, devicePixelRatio); + tree.style.setRelativeValues(remSize, remSize / devicePixelRatio); + + return tree; + } + + /// This is the recursive worker function for [_calculateRelativeValues] + static void _applyRelativeValuesRecursive(StyledElement tree, double remFontSize, double devicePixelRatio) { + //When we get to this point, there should be a valid fontSize at every level. + assert(tree.style.fontSize != null); + + final parentFontSize = tree.style.fontSize!.value; + + for (var child in tree.children) { + if (child.style.fontSize == null) { + child.style.fontSize = FontSize(parentFontSize); + } else { + switch (child.style.fontSize!.unit) { + case Unit.em: + child.style.fontSize = FontSize(parentFontSize * child.style.fontSize!.value); + break; + case Unit.percent: + child.style.fontSize = FontSize(parentFontSize * (child.style.fontSize!.value / 100.0)); + break; + case Unit.rem: + child.style.fontSize = FontSize(remFontSize * child.style.fontSize!.value); + break; + case Unit.px: + case Unit.auto: + //Ignore + break; + } + } + + // Note: it is necessary to scale down the emSize by the factor of + // devicePixelRatio since Flutter seems to calculates font sizes using + // physical pixels, but margins/padding using logical pixels. + final emSize = child.style.fontSize!.value / devicePixelRatio; + + tree.style.setRelativeValues(remFontSize, emSize); + + _applyRelativeValuesRecursive(child, remFontSize, devicePixelRatio); + } + } +} + +extension IterateLetters on String { + String nextLetter() { + String s = toLowerCase(); + if (s == 'z') { + return String.fromCharCode(s.codeUnitAt(0) - 25) + String.fromCharCode(s.codeUnitAt(0) - 25); // AA or aa + } else { + var lastChar = s.substring(s.length - 1); + var sub = s.substring(0, s.length - 1); + if (lastChar == 'z') { + // If a string of length > 1 ends in Z/z, + // increment the string (excluding the last Z/z) recursively, + // and append A/a (depending on casing) to it + return '${sub.nextLetter()}a'; + } else { + // (take till last char) append with (increment last char) + return sub + String.fromCharCode(lastChar.codeUnitAt(0) + 1); + } + } + } +} + +class RenderContext { + final BuildContext buildContext; + final HtmlParser parser; + final StyledElement tree; + final Style style; + final AnchorKey? key; + + RenderContext({ + required this.buildContext, + required this.parser, + required this.tree, + required this.style, + this.key, + }); +} \ No newline at end of file diff --git a/lib/core/html/html_viewer.dart b/lib/core/html/html_viewer.dart new file mode 100644 index 0000000..d83146f --- /dev/null +++ b/lib/core/html/html_viewer.dart @@ -0,0 +1,251 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:sonnat/core/extensions/number_extension.dart'; +import 'package:sonnat/core/html/custom_render.dart'; +import 'package:sonnat/core/html/flutter_html.dart'; +import 'package:sonnat/core/html/src/style/fontsize.dart'; +import 'package:sonnat/core/html/src/style/length.dart'; +import 'package:sonnat/core/html/src/style/lineheight.dart'; +import 'package:sonnat/core/html/string_proccess.dart'; +import 'package:sonnat/core/html/style.dart'; +import 'package:sonnat/core/player_widgets/audio_player.dart'; +import 'package:sonnat/core/player_widgets/video_player.dart'; +import 'package:sonnat/core/theme/app_colors.dart'; +import 'package:sonnat/core/theme/app_theme.dart'; +import 'package:sonnat/core/theme/reader_theme.dart'; +import 'package:sonnat/core/utils/app_utils.dart'; +import 'package:sonnat/core/widgets/show_image_widget.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class HTMLViewer extends StatelessWidget { + final String htmlContent; + final double fontSizeFactor; + final bool needToReplaceTags; + final ReaderTheme? theme; + final String? searchHighLight; + final double baseFontSize = 16.0; + final Color? textColor; + + const HTMLViewer({ + super.key, + required this.htmlContent, + this.fontSizeFactor = 1, + this.needToReplaceTags = false, + this.theme = ReaderTheme.light, + this.searchHighLight, + this.textColor, + }); + + @override + Widget build(BuildContext context) { + var style = AppTheme.instance.fontCreator( + 17, + FontWeights.regular, + AppColors.settingSemiBlack, + FontFamilyName.segoeui, + -0.0, + 1.5, + ); + + Widget html = Builder( + builder: (context) { + double lineHeight = Theme.of(context).textTheme.displayLarge?.height ?? 1.1; + return Html( + data: needToReplaceTags + ? htmlContent.replaceTHeader().replaceQHeader().replaceQText().replaceQAnswer().replaceTextStyle() + : htmlContent.replaceTextStyle(), + onLinkTap: (url, context, attributes, element) { + if (url == null) { + return; + } + launchUrl(Uri.parse(url)).then((value) { + return null; + }); + }, + customRenders: { + _stringMatcher('video'): CustomRender.widget(widget: (context, buildChildren) { + return _RoundFrame( + child: VideoPlayer( + url: context.tree.element!.attributes['src'] ?? '', + ), + ); + }), + _stringMatcher('img'): CustomRender.widget(widget: (renderContext, buildChildren) { + return GestureDetector( + onTap: () { + if (renderContext.tree.element!.attributes['src'] == null) { + return; + } + _openImage( + imageUrl: renderContext.tree.element!.attributes['src'] ?? '', + context: context, + ); + }, + child: _RoundFrame( + child: CachedNetworkImage( + imageUrl: renderContext.tree.element!.attributes['src'] ?? '', + ), + ), + ); + }), + _stringMatcher('audio'): CustomRender.widget(widget: (context, buildChildren) { + return AudioPlayer( + url: context.tree.element!.nodes[1].attributes['src'] ?? '', + ); + }), + _stringMatcher('q_header'): CustomRender.widget(widget: (context, buildChildren) { + if (context.tree.element?.hasChildNodes() ?? false) { + if (context.tree.element?.firstChild?.text != null) { + String txt = context.tree.element?.firstChild?.text ?? ''; + return QHeaderTextShower( + title: txt, + searchHighLight: searchHighLight, + fontSizeFactor: fontSizeFactor, + ); + } + } + return const _RoundFrame(child: SizedBox()); + }), + _stringMatcher('q_text'): CustomRender.widget(widget: (context, buildChildren) { + if (context.tree.element?.hasChildNodes() ?? false) { + if (context.tree.element?.firstChild?.text != null) { + String txt = context.tree.element?.firstChild?.text ?? ''; + return QTextShower( + title: txt, + searchHighLight: searchHighLight, + fontSizeFactor: fontSizeFactor, + theme: theme, + ); + } + } + return const _RoundFrame(child: SizedBox()); + }), + _stringMatcher('q_answer'): CustomRender.widget(widget: (context, buildChildren) { + if (context.tree.element?.hasChildNodes() ?? false) { + if (context.tree.element?.firstChild?.text != null) { + String txt = context.tree.element?.firstChild?.text ?? ''; + return QAnswerShower( + title: txt, + searchHighLight: searchHighLight, + fontSizeFactor: fontSizeFactor, + theme: theme, + ); + } + } + return const _RoundFrame(child: SizedBox()); + }), + _stringMatcher('t_header'): CustomRender.widget(widget: (context, buildChildren) { + if (context.tree.element?.hasChildNodes() ?? false) { + if (context.tree.element?.firstChild?.text != null) { + String txt = context.tree.element?.firstChild?.text ?? ''; + return THeaderTextShower( + title: txt, + searchHighLight: searchHighLight, + fontSizeFactor: fontSizeFactor, + theme: theme, + ); + } + } + return const _RoundFrame(child: SizedBox()); + }), + }, + style: { + 'p': Style( + color: textColor, + fontWeight: FontWeight.normal, + fontSize: FontSize(fontSizeFactor * 1.3, Unit.rem), + textAlign: TextAlign.justify, + ), + 'h1': Style( + color: textColor, + fontWeight: FontWeight.normal, + fontSize: FontSize(fontSizeFactor * 2.3, Unit.rem), + ), + 'h2': Style( + color: textColor, + fontWeight: FontWeight.normal, + fontSize: FontSize(fontSizeFactor * 2.1, Unit.rem), + ), + 'h3': Style( + color: textColor, + fontWeight: FontWeight.normal, + fontSize: FontSize(fontSizeFactor * 1.9, Unit.rem), + ), + 'h4': Style( + color: textColor, + fontWeight: FontWeight.normal, + fontSize: FontSize(fontSizeFactor * 1.7, Unit.rem), + ), + 'h5': Style( + color: textColor, + fontWeight: FontWeight.normal, + fontSize: FontSize(fontSizeFactor * 1.6, Unit.rem), + ), + 'h6': Style( + color: textColor, + fontWeight: FontWeight.normal, + fontSize: FontSize(fontSizeFactor * 1.4, Unit.rem), + ), + 'li': Style( + fontWeight: FontWeight.normal, + fontSize: FontSize(fontSizeFactor * 1.3, Unit.rem), + ), + 'a': Style( + color: textColor, + fontWeight: FontWeight.normal, + fontSize: FontSize(fontSizeFactor * 1.3, Unit.rem), + ), + 'ol': Style( + fontWeight: FontWeight.normal, + fontSize: FontSize(fontSizeFactor * 1.3, Unit.rem), + ), + 'html': Style( + fontSize: FontSize(baseFontSize * fontSizeFactor), + ), + '*': Style.fromTextStyle(style).copyWith( + color: textColor, + lineHeight: LineHeight.rem(lineHeight), + fontSize: FontSize(fontSizeFactor * baseFontSize), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + }, + tagsList: Html.tags..addAll(['flutter', 'q_header', 'q_text', 'q_answer', 't_header']), + ); + }, + ); + + return Padding( + padding: Utils.instance.singleMargin(left: 15, right: 15, bottom: 60.h), + child: html, + ); + } + + void _openImage({required String imageUrl, required BuildContext context}) { + Navigator.push(context, MaterialPageRoute( + builder: (context) { + return ShowImageWidget(imageUrl); + }, + )); + } +} + +CustomRenderMatcher _stringMatcher(String tag) => (context) => context.tree.element?.localName == tag; + +class _RoundFrame extends StatelessWidget { + final Widget child; + final bool hasFullWidth; + + const _RoundFrame({super.key, required this.child, this.hasFullWidth = true}); + + @override + Widget build(BuildContext context) { + return Container( + width: hasFullWidth ? 1.sw : null, + margin: Utils.instance.singleMargin(top: 7, bottom: 7), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: child, + ), + ); + } +} diff --git a/lib/core/html/src/anchor.dart b/lib/core/html/src/anchor.dart new file mode 100644 index 0000000..1cd7120 --- /dev/null +++ b/lib/core/html/src/anchor.dart @@ -0,0 +1,45 @@ +import 'package:flutter/widgets.dart'; +import 'package:sonnat/core/html/src/styled_element.dart'; + +class AnchorKey extends GlobalKey { + static final Set _registry = {}; + + final Key parentKey; + final String id; + + const AnchorKey._(this.parentKey, this.id) : super.constructor(); + + static AnchorKey? of(Key? parentKey, StyledElement? id) { + final key = forId(parentKey, id?.elementId); + if (key == null || _registry.contains(key)) { + // Invalid id or already created a key with this id: silently ignore + return null; + } + _registry.add(key); + return key; + } + + static AnchorKey? forId(Key? parentKey, String? id) { + if (parentKey == null || id == null || id.isEmpty || id == '[[No ID]]') { + return null; + } + + return AnchorKey._(parentKey, id); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AnchorKey && + runtimeType == other.runtimeType && + parentKey == other.parentKey && + id == other.id; + + @override + int get hashCode => parentKey.hashCode ^ id.hashCode; + + @override + String toString() { + return 'AnchorKey{parentKey: $parentKey, id: #$id}'; + } +} diff --git a/lib/core/html/src/css_box_widget.dart b/lib/core/html/src/css_box_widget.dart new file mode 100644 index 0000000..7ffffdc --- /dev/null +++ b/lib/core/html/src/css_box_widget.dart @@ -0,0 +1,734 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:sonnat/core/html/src/style/length.dart'; +import 'package:sonnat/core/html/src/style/margin.dart'; +import 'package:sonnat/core/html/src/style/size.dart'; +import 'package:sonnat/core/html/style.dart'; + +class CssBoxWidget extends StatelessWidget { + const CssBoxWidget({ + super.key, + required this.child, + required this.style, + this.textDirection, + this.childIsReplaced = false, + this.shrinkWrap = false, + }); + + /// Generates a CSSBoxWidget that contains a list of InlineSpan children. + CssBoxWidget.withInlineSpanChildren({ + super.key, + required List children, + required this.style, + this.textDirection, + this.childIsReplaced = false, + this.shrinkWrap = false, + bool selectable = false, + TextSelectionControls? selectionControls, + ScrollPhysics? scrollPhysics, + }) : child = selectable + ? _generateSelectableWidgetChild( + children, + style, + selectionControls, + scrollPhysics, + ) + : _generateWidgetChild(children, style); + + /// The child to be rendered within the CSS Box. + final Widget child; + + /// The style to use to compute this box's margins/padding/box decoration/width/height/etc. + /// + /// Note that this style will only apply to this box, and will not cascade to its child. + final Style style; + + /// Sets the direction the text of this widget should flow. If unset or null, + /// the nearest Directionality ancestor is used as a default. If that cannot + /// be found, this Widget's renderer will raise an assertion. + final TextDirection? textDirection; + + /// Indicates whether this child is a replaced element that manages its own width + /// (e.g. img, video, iframe, audio, etc.) + final bool childIsReplaced; + + /// Whether or not the content should ignore auto horizontal margins and not + /// necessarily take up the full available width unless necessary + final bool shrinkWrap; + + @override + Widget build(BuildContext context) { + final markerBox = style.listStylePosition == ListStylePosition.outside ? _generateMarkerBoxSpan(style) : null; + + return _CSSBoxRenderer( + width: style.width ?? Width.auto(), + height: style.height ?? Height.auto(), + paddingSize: style.padding?.collapsedSize ?? Size.zero, + borderSize: style.border?.dimensions.collapsedSize ?? Size.zero, + margins: style.margin ?? Margins.zero, + display: style.display ?? Display.inline, + childIsReplaced: childIsReplaced, + emValue: _calculateEmValue(style, context), + textDirection: _checkTextDirection(context, textDirection), + shrinkWrap: shrinkWrap, + children: [ + Container( + decoration: BoxDecoration( + border: style.border, + color: style.backgroundColor, //Colors the padding and content boxes + ), + width: _shouldExpandToFillBlock() ? double.infinity : null, + padding: style.padding ?? EdgeInsets.zero, + child: child, + ), + if (markerBox != null) Text.rich(markerBox), + ], + ); + } + + /// Takes a list of InlineSpan children and generates a Text.rich Widget + /// containing those children. + static Widget _generateWidgetChild(List children, Style style) { + if (children.isEmpty) { + return Container(); + } + + // Generate an inline marker box if the list-style-position is set to + // inside. Otherwise the marker box will be added elsewhere. + if (style.listStylePosition == ListStylePosition.inside) { + final inlineMarkerBox = _generateMarkerBoxSpan(style); + if (inlineMarkerBox != null) { + children.insert(0, inlineMarkerBox); + } + } + + return RichText( + text: TextSpan( + style: style.generateTextStyle(), + children: children, + ), + textAlign: style.textAlign ?? TextAlign.start, + textDirection: style.direction, + maxLines: style.maxLines, + overflow: style.textOverflow ?? TextOverflow.clip, + ); + } + + static Widget _generateSelectableWidgetChild( + List children, + Style style, + TextSelectionControls? selectionControls, + ScrollPhysics? scrollPhysics, + ) { + if (children.isEmpty) { + return Container(); + } + + return SelectableText.rich( + TextSpan( + style: style.generateTextStyle(), + children: children, + ), + style: style.generateTextStyle(), + textAlign: style.textAlign, + textDirection: style.direction, + maxLines: style.maxLines, + selectionControls: selectionControls, + scrollPhysics: scrollPhysics, + ); + } + + static InlineSpan? _generateMarkerBoxSpan(Style style) { + if (style.display == Display.listItem) { + // First handle listStyleImage + if (style.listStyleImage != null) { + return WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Image.network( + style.listStyleImage!.uriText, + errorBuilder: (_, __, ___) { + if (style.marker?.content.replacementContent?.isNotEmpty ?? false) { + return Text.rich( + TextSpan( + text: style.marker!.content.replacementContent!, + style: style.marker!.style?.generateTextStyle(), + ), + ); + } + + return Container(); + }, + ), + ); + } + + // Display list marker with given style + if (style.marker?.content.replacementContent?.isNotEmpty ?? false) { + return TextSpan( + text: style.marker!.content.replacementContent!, + style: style.marker!.style?.generateTextStyle(), + ); + } + } + + return null; + } + + /// Whether or not the content-box should expand its width to fill the + /// width available to it or if it should just let its inner content + /// determine the content-box's width. + bool _shouldExpandToFillBlock() { + return (style.display == Display.block || style.display == Display.listItem) && !childIsReplaced && !shrinkWrap; + } + + TextDirection _checkTextDirection(BuildContext context, TextDirection? direction) { + final textDirection = direction ?? Directionality.maybeOf(context); + + assert( + textDirection != null, + 'CSSBoxWidget needs either a Directionality ancestor or a provided textDirection', + ); + + return textDirection!; + } +} + +class _CSSBoxRenderer extends MultiChildRenderObjectWidget { + const _CSSBoxRenderer({ + required super.children, + required this.display, + required this.margins, + required this.width, + required this.height, + required this.borderSize, + required this.paddingSize, + required this.textDirection, + required this.childIsReplaced, + required this.emValue, + required this.shrinkWrap, + }); + + /// The Display type of the element + final Display display; + + /// The computed margin values for this element + final Margins margins; + + /// The width of the element + final Width width; + + /// The height of the element + final Height height; + + /// The collapsed size of the element's border + final Size borderSize; + + /// The collapsed size of the element's padding + final Size paddingSize; + + /// The direction for this widget's text to flow. + final TextDirection textDirection; + + /// Whether or not the child being rendered is a replaced element + /// (this changes the rules for rendering) + final bool childIsReplaced; + + /// The calculated size of 1em in pixels + final double emValue; + + /// Whether or not this container should shrinkWrap its contents. + /// (see definition on [CSSBoxWidget]) + final bool shrinkWrap; + + @override + _RenderCSSBox createRenderObject(BuildContext context) { + return _RenderCSSBox( + display: display, + width: width..normalize(emValue), + height: height..normalize(emValue), + margins: _preProcessMargins(margins, shrinkWrap), + borderSize: borderSize, + paddingSize: paddingSize, + textDirection: textDirection, + childIsReplaced: childIsReplaced, + shrinkWrap: shrinkWrap, + ); + } + + @override + void updateRenderObject(BuildContext context, _RenderCSSBox renderObject) { + renderObject + ..display = display + ..width = (width..normalize(emValue)) + ..height = (height..normalize(emValue)) + ..margins = _preProcessMargins(margins, shrinkWrap) + ..borderSize = borderSize + ..paddingSize = paddingSize + ..textDirection = textDirection + ..childIsReplaced = childIsReplaced + ..shrinkWrap = shrinkWrap; + } + + Margins _preProcessMargins(Margins margins, bool shrinkWrap) { + Margin leftMargin = margins.left ?? Margin.zero(); + Margin rightMargin = margins.right ?? Margin.zero(); + Margin topMargin = margins.top ?? Margin.zero(); + Margin bottomMargin = margins.bottom ?? Margin.zero(); + + //Preprocess margins to a pixel value + leftMargin.normalize(emValue); + rightMargin.normalize(emValue); + topMargin.normalize(emValue); + bottomMargin.normalize(emValue); + + // See https://drafts.csswg.org/css2/#inline-width + // and https://drafts.csswg.org/css2/#inline-replaced-width + // and https://drafts.csswg.org/css2/#inlineblock-width + // and https://drafts.csswg.org/css2/#inlineblock-replaced-width + if (display == Display.inline || display == Display.inlineBlock) { + if (margins.left?.unit == Unit.auto) { + leftMargin = Margin.zero(); + } + if (margins.right?.unit == Unit.auto) { + rightMargin = Margin.zero(); + } + } + + //Shrink-wrap margins if applicable + if (shrinkWrap && leftMargin.unit == Unit.auto) { + leftMargin = Margin.zero(); + } + + if (shrinkWrap && rightMargin.unit == Unit.auto) { + rightMargin = Margin.zero(); + } + + return Margins( + top: topMargin, + right: rightMargin, + bottom: bottomMargin, + left: leftMargin, + ); + } +} + +/// Implements the CSS layout algorithm +class _RenderCSSBox extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + _RenderCSSBox({ + required Display display, + required Width width, + required Height height, + required Margins margins, + required Size borderSize, + required Size paddingSize, + required TextDirection textDirection, + required bool childIsReplaced, + required bool shrinkWrap, + }) : _display = display, + _width = width, + _height = height, + _margins = margins, + _borderSize = borderSize, + _paddingSize = paddingSize, + _textDirection = textDirection, + _childIsReplaced = childIsReplaced, + _shrinkWrap = shrinkWrap; + + Display _display; + + Display get display => _display; + + set display(Display display) { + _display = display; + markNeedsLayout(); + } + + Width _width; + + Width get width => _width; + + set width(Width width) { + _width = width; + markNeedsLayout(); + } + + Height _height; + + Height get height => _height; + + set height(Height height) { + _height = height; + markNeedsLayout(); + } + + Margins _margins; + + Margins get margins => _margins; + + set margins(Margins margins) { + _margins = margins; + markNeedsLayout(); + } + + Size _borderSize; + + Size get borderSize => _borderSize; + + set borderSize(Size size) { + _borderSize = size; + markNeedsLayout(); + } + + Size _paddingSize; + + Size get paddingSize => _paddingSize; + + set paddingSize(Size size) { + _paddingSize = size; + markNeedsLayout(); + } + + TextDirection _textDirection; + + TextDirection get textDirection => _textDirection; + + set textDirection(TextDirection textDirection) { + _textDirection = textDirection; + markNeedsLayout(); + } + + bool _childIsReplaced; + + bool get childIsReplaced => _childIsReplaced; + + set childIsReplaced(bool childIsReplaced) { + _childIsReplaced = childIsReplaced; + markNeedsLayout(); + } + + bool _shrinkWrap; + + bool get shrinkWrap => _shrinkWrap; + + set shrinkWrap(bool shrinkWrap) { + _shrinkWrap = shrinkWrap; + markNeedsLayout(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! CSSBoxParentData) { + child.parentData = CSSBoxParentData(); + } + } + + static double getIntrinsicDimension(RenderBox? firstChild, double Function(RenderBox child) mainChildSizeGetter) { + double extent = 0.0; + RenderBox? child = firstChild; + while (child != null) { + final CSSBoxParentData childParentData = child.parentData! as CSSBoxParentData; + extent = math.max(extent, mainChildSizeGetter(child)); + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + return extent; + } + + @override + double computeMinIntrinsicWidth(double height) { + return getIntrinsicDimension(firstChild, (child) => child.getMinIntrinsicWidth(height)); + } + + @override + double computeMaxIntrinsicWidth(double height) { + return getIntrinsicDimension(firstChild, (child) => child.getMaxIntrinsicWidth(height)); + } + + @override + double computeMinIntrinsicHeight(double width) { + return getIntrinsicDimension(firstChild, (child) => child.getMinIntrinsicHeight(width)); + } + + @override + double computeMaxIntrinsicHeight(double width) { + return getIntrinsicDimension(firstChild, (child) => child.getMaxIntrinsicHeight(width)); + } + + @override + double? computeDistanceToActualBaseline(TextBaseline baseline) { + return firstChild?.getDistanceToActualBaseline(baseline); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return _computeSize( + constraints: constraints, + layoutChild: ChildLayoutHelper.dryLayoutChild, + ).parentSize; + } + + _Sizes _computeSize({required BoxConstraints constraints, required ChildLayouter layoutChild}) { + if (childCount == 0) { + return _Sizes(constraints.biggest, Size.zero); + } + + Size containingBlockSize = constraints.biggest; + double width = containingBlockSize.width; + double height = containingBlockSize.height; + + assert(firstChild != null); + RenderBox child = firstChild!; + + final CSSBoxParentData parentData = child.parentData! as CSSBoxParentData; + RenderBox? markerBoxChild = parentData.nextSibling; + + // Calculate child size + final childConstraints = constraints.copyWith( + maxWidth: (this.width.unit != Unit.auto) + ? this.width.value + : containingBlockSize.width - (margins.left?.value ?? 0) - (margins.right?.value ?? 0), + maxHeight: (this.height.unit != Unit.auto) + ? this.height.value + : containingBlockSize.height - (margins.top?.value ?? 0) - (margins.bottom?.value ?? 0), + minWidth: (this.width.unit != Unit.auto) ? this.width.value : 0, + minHeight: (this.height.unit != Unit.auto) ? this.height.value : 0, + ); + final Size childSize = layoutChild(child, childConstraints); + if (markerBoxChild != null) { + layoutChild(markerBoxChild, childConstraints); + } + + // Calculate used values of margins based on rules + final usedMargins = _calculateUsedMargins(childSize, containingBlockSize); + final horizontalMargins = (usedMargins.left?.value ?? 0) + (usedMargins.right?.value ?? 0); + final verticalMargins = (usedMargins.top?.value ?? 0) + (usedMargins.bottom?.value ?? 0); + + //Calculate Width and Height of CSS Box + height = childSize.height; + switch (display) { + case Display.block: + width = (shrinkWrap || childIsReplaced) ? childSize.width + horizontalMargins : containingBlockSize.width; + height = childSize.height + verticalMargins; + break; + case Display.inline: + width = childSize.width + horizontalMargins; + height = childSize.height; + break; + case Display.inlineBlock: + width = childSize.width + horizontalMargins; + height = childSize.height + verticalMargins; + break; + case Display.listItem: + width = shrinkWrap ? childSize.width + horizontalMargins : containingBlockSize.width; + height = childSize.height + verticalMargins; + break; + case Display.none: + width = 0; + height = 0; + break; + } + + return _Sizes(constraints.constrain(Size(width, height)), childSize); + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + + final sizes = _computeSize( + constraints: constraints, + layoutChild: ChildLayoutHelper.layoutChild, + ); + size = sizes.parentSize; + + assert(firstChild != null); + RenderBox child = firstChild!; + + final CSSBoxParentData childParentData = child.parentData! as CSSBoxParentData; + + // Calculate used margins based on constraints and child size + final usedMargins = _calculateUsedMargins(sizes.childSize, constraints.biggest); + final leftMargin = usedMargins.left?.value ?? 0; + final topMargin = usedMargins.top?.value ?? 0; + + double leftOffset = 0; + double topOffset = 0; + switch (display) { + case Display.block: + leftOffset = leftMargin; + topOffset = topMargin; + break; + case Display.inline: + leftOffset = leftMargin; + break; + case Display.inlineBlock: + leftOffset = leftMargin; + topOffset = topMargin; + break; + case Display.listItem: + leftOffset = leftMargin; + topOffset = topMargin; + break; + case Display.none: + //No offset + break; + } + childParentData.offset = Offset(leftOffset, topOffset); + assert(child.parentData == childParentData); + + // Now, layout the marker box if it exists: + RenderBox? markerBox = childParentData.nextSibling; + if (markerBox != null) { + final markerBoxParentData = markerBox.parentData! as CSSBoxParentData; + final distance = (child.getDistanceToBaseline(TextBaseline.alphabetic, onlyReal: true) ?? 0) + topOffset; + final offsetHeight = + distance - (markerBox.getDistanceToBaseline(TextBaseline.alphabetic) ?? markerBox.size.height); + markerBoxParentData.offset = Offset(-markerBox.size.width, offsetHeight); + } + } + + Margins _calculateUsedMargins(Size childSize, Size containingBlockSize) { + //We assume that margins have already been preprocessed + // (i.e. they are non-null and either px units or auto. + assert(margins.left != null && margins.right != null); + assert(margins.left!.unit == Unit.px || margins.left!.unit == Unit.auto); + assert(margins.right!.unit == Unit.px || margins.right!.unit == Unit.auto); + + Margin marginLeft = margins.left!; + Margin marginRight = margins.right!; + + bool widthIsAuto = width.unit == Unit.auto; + bool marginLeftIsAuto = marginLeft.unit == Unit.auto; + bool marginRightIsAuto = marginRight.unit == Unit.auto; + + if (display == Display.block) { + if (childIsReplaced) { + widthIsAuto = false; + } + + if (shrinkWrap) { + widthIsAuto = false; + } + + //If width is not auto and the width of the margin box is larger than the + // width of the containing block, then consider left and right margins to + // have a 0 value. + if (!widthIsAuto) { + if ((childSize.width + marginLeft.value + marginRight.value) > containingBlockSize.width) { + //Treat auto values of margin left and margin right as 0 for following rules + marginLeft = Margin(0); + marginRight = Margin(0); + marginLeftIsAuto = false; + marginRightIsAuto = false; + } + } + + // If all values are non-auto, the box is overconstrained. + // One of the margins will need to be adjusted so that the + // entire width of the containing block is used. + if (!widthIsAuto && !marginLeftIsAuto && !marginRightIsAuto && !shrinkWrap && !childIsReplaced) { + //Ignore either left or right margin based on textDirection. + + switch (textDirection) { + case TextDirection.rtl: + final difference = containingBlockSize.width - childSize.width - marginRight.value; + marginLeft = Margin(difference); + break; + case TextDirection.ltr: + final difference = containingBlockSize.width - childSize.width - marginLeft.value; + marginRight = Margin(difference); + break; + } + } + + // If there is exactly one value specified as auto, compute it value from the equality (our widths are already set) + if (widthIsAuto && !marginLeftIsAuto && !marginRightIsAuto) { + widthIsAuto = false; + } else if (!widthIsAuto && marginLeftIsAuto && !marginRightIsAuto) { + marginLeft = Margin(containingBlockSize.width - childSize.width - marginRight.value); + marginLeftIsAuto = false; + } else if (!widthIsAuto && !marginLeftIsAuto && marginRightIsAuto) { + marginRight = Margin(containingBlockSize.width - childSize.width - marginLeft.value); + marginRightIsAuto = false; + } + + //If width is set to auto, any other auto values become 0, and width + // follows from the resulting equality. + if (widthIsAuto) { + if (marginLeftIsAuto) { + marginLeft = Margin(0); + marginLeftIsAuto = false; + } + if (marginRightIsAuto) { + marginRight = Margin(0); + marginRightIsAuto = false; + } + widthIsAuto = false; + } + + //If both margin-left and margin-right are auto, their used values are equal. + // This horizontally centers the element within the containing block. + if (marginLeftIsAuto && marginRightIsAuto) { + final newMargin = Margin((containingBlockSize.width - childSize.width) / 2); + marginLeft = newMargin; + marginRight = newMargin; + marginLeftIsAuto = false; + marginRightIsAuto = false; + } + + //Assert that all auto values have been assigned. + assert(!marginLeftIsAuto && !marginRightIsAuto && !widthIsAuto); + } + + return Margins(left: marginLeft, right: marginRight, top: margins.top, bottom: margins.bottom); + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + return defaultHitTestChildren(result, position: position); + } + + @override + void paint(PaintingContext context, Offset offset) { + defaultPaint(context, offset); + } +} + +extension Normalize on Dimension { + void normalize(double emValue) { + switch (unit) { + case Unit.rem: + // Because CSSBoxWidget doesn't have any information about any + // sort of tree structure, treat rem the same as em. The HtmlParser + // widget handles rem/em values before they get to CSSBoxWidget. + case Unit.em: + value *= emValue; + unit = Unit.px; + return; + case Unit.px: + case Unit.auto: + case Unit.percent: + return; + } + } +} + +double _calculateEmValue(Style style, BuildContext buildContext) { + return (style.fontSize?.emValue ?? 16) * + MediaQuery.textScaleFactorOf(buildContext) * + MediaQuery.of(buildContext).devicePixelRatio; +} + +class CSSBoxParentData extends ContainerBoxParentData {} + +class _Sizes { + final Size parentSize; + final Size childSize; + + const _Sizes(this.parentSize, this.childSize); +} diff --git a/lib/core/html/src/css_parser.dart b/lib/core/html/src/css_parser.dart new file mode 100644 index 0000000..8e81a01 --- /dev/null +++ b/lib/core/html/src/css_parser.dart @@ -0,0 +1,1172 @@ +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:csslib/parser.dart' as cssparser; +import 'package:csslib/visitor.dart' as css; +import 'package:flutter/material.dart'; +import 'package:sonnat/core/html/html_parser.dart'; +import 'package:sonnat/core/html/src/style/fontsize.dart'; +import 'package:sonnat/core/html/src/style/length.dart'; +import 'package:sonnat/core/html/src/style/lineheight.dart'; +import 'package:sonnat/core/html/src/style/margin.dart'; +import 'package:sonnat/core/html/src/style/size.dart'; +import 'package:sonnat/core/html/src/utils.dart'; +import 'package:sonnat/core/html/style.dart'; + +Style declarationsToStyle(Map> declarations) { + Style style = Style(); + declarations.forEach((property, value) { + if (value.isNotEmpty) { + switch (property) { + case 'background-color': + style.backgroundColor = ExpressionMapping.expressionToColor(value.first) ?? style.backgroundColor; + break; + case 'border': + List? borderWidths = value.whereType().toList(); + + /// List might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping] + borderWidths.removeWhere((element) => + element == null || + (element.text != 'thin' && + element.text != 'medium' && + element.text != 'thick' && + element is! css.LengthTerm && + element is! css.PercentageTerm && + element is! css.EmTerm && + element is! css.RemTerm && + element is! css.NumberTerm)); + List? borderColors = + value.where((element) => ExpressionMapping.expressionToColor(element) != null).toList(); + List? potentialStyles = value.whereType().toList(); + + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. + List possibleBorderValues = [ + 'dotted', + 'dashed', + 'solid', + 'double', + 'groove', + 'ridge', + 'inset', + 'outset', + 'none', + 'hidden' + ]; + + /// List might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping] + potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text)); + List? borderStyles = potentialStyles; + style.border = ExpressionMapping.expressionToBorder(borderWidths, borderStyles, borderColors); + break; + case 'border-left': + List? borderWidths = value.whereType().toList(); + + /// List might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping] + borderWidths.removeWhere((element) => + element == null || + (element.text != 'thin' && + element.text != 'medium' && + element.text != 'thick' && + element is! css.LengthTerm && + element is! css.PercentageTerm && + element is! css.EmTerm && + element is! css.RemTerm && + element is! css.NumberTerm)); + css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null); + css.Expression? borderColor = + value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null); + List? potentialStyles = value.whereType().toList(); + + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. + List possibleBorderValues = [ + 'dotted', + 'dashed', + 'solid', + 'double', + 'groove', + 'ridge', + 'inset', + 'outset', + 'none', + 'hidden' + ]; + + /// List might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping] + potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text)); + css.LiteralTerm? borderStyle = potentialStyles.firstOrNull; + Border newBorder = Border( + left: style.border?.left.copyWith( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor), + ) ?? + BorderSide( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black, + ), + right: style.border?.right ?? BorderSide.none, + top: style.border?.top ?? BorderSide.none, + bottom: style.border?.bottom ?? BorderSide.none, + ); + style.border = newBorder; + break; + case 'border-right': + List? borderWidths = value.whereType().toList(); + + /// List might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping] + borderWidths.removeWhere((element) => + element == null || + (element.text != 'thin' && + element.text != 'medium' && + element.text != 'thick' && + element is! css.LengthTerm && + element is! css.PercentageTerm && + element is! css.EmTerm && + element is! css.RemTerm && + element is! css.NumberTerm)); + css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null); + css.Expression? borderColor = + value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null); + List? potentialStyles = value.whereType().toList(); + + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. + List possibleBorderValues = [ + 'dotted', + 'dashed', + 'solid', + 'double', + 'groove', + 'ridge', + 'inset', + 'outset', + 'none', + 'hidden' + ]; + + /// List might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping] + potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text)); + css.LiteralTerm? borderStyle = potentialStyles.firstOrNull; + Border newBorder = Border( + left: style.border?.left ?? BorderSide.none, + right: style.border?.right.copyWith( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor), + ) ?? + BorderSide( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black, + ), + top: style.border?.top ?? BorderSide.none, + bottom: style.border?.bottom ?? BorderSide.none, + ); + style.border = newBorder; + break; + case 'border-top': + List? borderWidths = value.whereType().toList(); + + /// List might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping] + borderWidths.removeWhere((element) => + element == null || + (element.text != 'thin' && + element.text != 'medium' && + element.text != 'thick' && + element is! css.LengthTerm && + element is! css.PercentageTerm && + element is! css.EmTerm && + element is! css.RemTerm && + element is! css.NumberTerm)); + css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null); + css.Expression? borderColor = + value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null); + List? potentialStyles = value.whereType().toList(); + + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. + List possibleBorderValues = [ + 'dotted', + 'dashed', + 'solid', + 'double', + 'groove', + 'ridge', + 'inset', + 'outset', + 'none', + 'hidden' + ]; + + /// List might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping] + potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text)); + css.LiteralTerm? borderStyle = potentialStyles.firstOrNull; + Border newBorder = Border( + left: style.border?.left ?? BorderSide.none, + right: style.border?.right ?? BorderSide.none, + top: style.border?.top.copyWith( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor), + ) ?? + BorderSide( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black, + ), + bottom: style.border?.bottom ?? BorderSide.none, + ); + style.border = newBorder; + break; + case 'border-bottom': + List? borderWidths = value.whereType().toList(); + + /// List might include other values than the ones we want for [BorderSide.width], so make sure to remove those before passing it to [ExpressionMapping] + borderWidths.removeWhere((element) => + element == null || + (element.text != 'thin' && + element.text != 'medium' && + element.text != 'thick' && + element is! css.LengthTerm && + element is! css.PercentageTerm && + element is! css.EmTerm && + element is! css.RemTerm && + element is! css.NumberTerm)); + css.LiteralTerm? borderWidth = borderWidths.firstWhereOrNull((element) => element != null); + css.Expression? borderColor = + value.firstWhereOrNull((element) => ExpressionMapping.expressionToColor(element) != null); + List? potentialStyles = value.whereType().toList(); + + /// Currently doesn't matter, as Flutter only supports "solid" or "none", but may support more in the future. + List possibleBorderValues = [ + 'dotted', + 'dashed', + 'solid', + 'double', + 'groove', + 'ridge', + 'inset', + 'outset', + 'none', + 'hidden' + ]; + + /// List might include other values than the ones we want for [BorderSide.style], so make sure to remove those before passing it to [ExpressionMapping] + potentialStyles.removeWhere((element) => element == null || !possibleBorderValues.contains(element.text)); + css.LiteralTerm? borderStyle = potentialStyles.firstOrNull; + Border newBorder = Border( + left: style.border?.left ?? BorderSide.none, + right: style.border?.right ?? BorderSide.none, + top: style.border?.top ?? BorderSide.none, + bottom: style.border?.bottom.copyWith( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor), + ) ?? + BorderSide( + width: ExpressionMapping.expressionToBorderWidth(borderWidth), + style: ExpressionMapping.expressionToBorderStyle(borderStyle), + color: ExpressionMapping.expressionToColor(borderColor) ?? Colors.black, + ), + ); + style.border = newBorder; + break; + case 'color': + style.color = ExpressionMapping.expressionToColor(value.first) ?? style.color; + break; + case 'direction': + style.direction = ExpressionMapping.expressionToDirection(value.first); + break; + case 'display': + style.display = ExpressionMapping.expressionToDisplay(value.first); + break; + case 'line-height': + style.lineHeight = ExpressionMapping.expressionToLineHeight(value.first); + break; + case 'font-family': + style.fontFamily = ExpressionMapping.expressionToFontFamily(value.first) ?? style.fontFamily; + break; + case 'font-feature-settings': + style.fontFeatureSettings = ExpressionMapping.expressionToFontFeatureSettings(value); + break; + case 'font-size': + style.fontSize = ExpressionMapping.expressionToFontSize(value.first) ?? style.fontSize; + break; + case 'font-style': + style.fontStyle = ExpressionMapping.expressionToFontStyle(value.first); + break; + case 'font-weight': + style.fontWeight = ExpressionMapping.expressionToFontWeight(value.first); + break; + case 'list-style': + css.LiteralTerm? position = + value.firstWhereOrNull((e) => e is css.LiteralTerm && (e.text == 'outside' || e.text == 'inside')) + as css.LiteralTerm?; + css.UriTerm? image = value.firstWhereOrNull((e) => e is css.UriTerm) as css.UriTerm?; + css.LiteralTerm? type = + value.firstWhereOrNull((e) => e is css.LiteralTerm && e.text != 'outside' && e.text != 'inside') + as css.LiteralTerm?; + if (position != null) { + switch (position.text) { + case 'outside': + style.listStylePosition = ListStylePosition.outside; + break; + case 'inside': + style.listStylePosition = ListStylePosition.inside; + break; + } + } + if (image != null) { + style.listStyleImage = ExpressionMapping.expressionToListStyleImage(image) ?? style.listStyleImage; + } else if (type != null) { + style.listStyleType = ExpressionMapping.expressionToListStyleType(type) ?? style.listStyleType; + } + break; + case 'list-style-image': + if (value.first is css.UriTerm) { + style.listStyleImage = + ExpressionMapping.expressionToListStyleImage(value.first as css.UriTerm) ?? style.listStyleImage; + } + break; + case 'list-style-position': + if (value.first is css.LiteralTerm) { + switch ((value.first as css.LiteralTerm).text) { + case 'outside': + style.listStylePosition = ListStylePosition.outside; + break; + case 'inside': + style.listStylePosition = ListStylePosition.inside; + break; + } + } + break; + case 'height': + style.height = ExpressionMapping.expressionToHeight(value.first) ?? style.height; + break; + case 'list-style-type': + if (value.first is css.LiteralTerm) { + style.listStyleType = + ExpressionMapping.expressionToListStyleType(value.first as css.LiteralTerm) ?? style.listStyleType; + } + break; + case 'margin': + List? marginLengths = value.whereType().toList(); + + /// List might include other values than the ones we want for margin length, so make sure to remove those before passing it to [ExpressionMapping] + marginLengths.removeWhere((element) => + element is! css.LengthTerm && + element is! css.EmTerm && + element is! css.RemTerm && + element is! css.NumberTerm && + !(element.text == 'auto')); + Margins margin = ExpressionMapping.expressionToMargins(marginLengths); + style.margin = (style.margin ?? Margins.all(0)).copyWith( + left: margin.left, + right: margin.right, + top: margin.top, + bottom: margin.bottom, + ); + break; + case 'margin-left': + style.margin = + (style.margin ?? Margins.zero).copyWith(left: ExpressionMapping.expressionToMargin(value.first)); + break; + case 'margin-right': + style.margin = + (style.margin ?? Margins.zero).copyWith(right: ExpressionMapping.expressionToMargin(value.first)); + break; + case 'margin-top': + style.margin = + (style.margin ?? Margins.zero).copyWith(top: ExpressionMapping.expressionToMargin(value.first)); + break; + case 'margin-bottom': + style.margin = + (style.margin ?? Margins.zero).copyWith(bottom: ExpressionMapping.expressionToMargin(value.first)); + break; + case 'padding': + List? paddingLengths = value.whereType().toList(); + + /// List might include other values than the ones we want for padding length, so make sure to remove those before passing it to [ExpressionMapping] + paddingLengths.removeWhere((element) => + element is! css.LengthTerm && + element is! css.EmTerm && + element is! css.RemTerm && + element is! css.NumberTerm); + List padding = ExpressionMapping.expressionToPadding(paddingLengths); + style.padding = (style.padding ?? EdgeInsets.zero).copyWith( + left: padding[0], + right: padding[1], + top: padding[2], + bottom: padding[3], + ); + break; + case 'padding-left': + style.padding = (style.padding ?? EdgeInsets.zero) + .copyWith(left: ExpressionMapping.expressionToPaddingLength(value.first)); + break; + case 'padding-right': + style.padding = (style.padding ?? EdgeInsets.zero) + .copyWith(right: ExpressionMapping.expressionToPaddingLength(value.first)); + break; + case 'padding-top': + style.padding = (style.padding ?? EdgeInsets.zero) + .copyWith(top: ExpressionMapping.expressionToPaddingLength(value.first)); + break; + case 'padding-bottom': + style.padding = (style.padding ?? EdgeInsets.zero) + .copyWith(bottom: ExpressionMapping.expressionToPaddingLength(value.first)); + break; + case 'text-align': + style.textAlign = ExpressionMapping.expressionToTextAlign(value.first); + break; + case 'text-decoration': + List? textDecorationList = value.whereType().toList(); + + /// List might include other values than the ones we want for [textDecorationList], so make sure to remove those before passing it to [ExpressionMapping] + textDecorationList.removeWhere((element) => + element == null || + (element.text != 'none' && + element.text != 'overline' && + element.text != 'underline' && + element.text != 'line-through')); + List? nullableList = value; + css.Expression? textDecorationColor; + textDecorationColor = + nullableList.firstWhereOrNull((element) => element is css.HexColorTerm || element is css.FunctionTerm); + List? potentialStyles = value.whereType().toList(); + + /// List might include other values than the ones we want for [textDecorationStyle], so make sure to remove those before passing it to [ExpressionMapping] + potentialStyles.removeWhere((element) => + element == null || + (element.text != 'solid' && + element.text != 'double' && + element.text != 'dashed' && + element.text != 'dotted' && + element.text != 'wavy')); + css.LiteralTerm? textDecorationStyle = potentialStyles.isNotEmpty ? potentialStyles.last : null; + style.textDecoration = ExpressionMapping.expressionToTextDecorationLine(textDecorationList); + if (textDecorationColor != null) { + style.textDecorationColor = + ExpressionMapping.expressionToColor(textDecorationColor) ?? style.textDecorationColor; + } + if (textDecorationStyle != null) { + style.textDecorationStyle = ExpressionMapping.expressionToTextDecorationStyle(textDecorationStyle); + } + break; + case 'text-decoration-color': + style.textDecorationColor = ExpressionMapping.expressionToColor(value.first) ?? style.textDecorationColor; + break; + case 'text-decoration-line': + List? textDecorationList = value.whereType().toList(); + style.textDecoration = ExpressionMapping.expressionToTextDecorationLine(textDecorationList); + break; + case 'text-decoration-style': + style.textDecorationStyle = ExpressionMapping.expressionToTextDecorationStyle(value.first as css.LiteralTerm); + break; + case 'text-shadow': + style.textShadow = ExpressionMapping.expressionToTextShadow(value); + break; + case 'text-transform': + final val = (value.first as css.LiteralTerm).text; + if (val == 'uppercase') { + style.textTransform = TextTransform.uppercase; + } else if (val == 'lowercase') { + style.textTransform = TextTransform.lowercase; + } else if (val == 'capitalize') { + style.textTransform = TextTransform.capitalize; + } else { + style.textTransform = TextTransform.none; + } + break; + case 'width': + style.width = ExpressionMapping.expressionToWidth(value.first) ?? style.width; + break; + } + } + }); + return style; +} + +Style? inlineCssToStyle(String? inlineStyle, OnCssParseError? errorHandler) { + var errors = []; + final sheet = cssparser.parse('*{$inlineStyle}', errors: errors); + if (errors.isEmpty) { + final declarations = DeclarationVisitor().getDeclarations(sheet); + return declarationsToStyle(declarations['*']!); + } else if (errorHandler != null) { + String? newCss = errorHandler.call(inlineStyle ?? '', errors); + if (newCss != null) { + return inlineCssToStyle(newCss, errorHandler); + } + } + return null; +} + +Map>> parseExternalCss(String css, OnCssParseError? errorHandler) { + var errors = []; + final sheet = cssparser.parse(css, errors: errors); + if (errors.isEmpty) { + return DeclarationVisitor().getDeclarations(sheet); + } else if (errorHandler != null) { + String? newCss = errorHandler.call(css, errors); + if (newCss != null) { + return parseExternalCss(newCss, errorHandler); + } + } + return {}; +} + +class DeclarationVisitor extends css.Visitor { + final Map>> _result = {}; + final Map> _properties = {}; + late String _selector; + late String _currentProperty; + + Map>> getDeclarations(css.StyleSheet sheet) { + for (var element in sheet.topLevels) { + if (element.span != null) { + _selector = element.span!.text; + element.visit(this); + if (_result[_selector] != null) { + _properties.forEach((key, value) { + if (_result[_selector]![key] != null) { + _result[_selector]![key]!.addAll(List.from(value)); + } else { + _result[_selector]![key] = List.from(value); + } + }); + } else { + _result[_selector] = Map>.from(_properties); + } + _properties.clear(); + } + } + return _result; + } + + @override + void visitDeclaration(css.Declaration node) { + _currentProperty = node.property; + _properties[_currentProperty] = []; + node.expression!.visit(this); + } + + @override + void visitExpressions(css.Expressions node) { + if (_properties[_currentProperty] != null) { + _properties[_currentProperty]!.addAll(node.expressions); + } else { + _properties[_currentProperty] = node.expressions; + } + } +} + +//Mapping functions +class ExpressionMapping { + static Border expressionToBorder( + List? borderWidths, List? borderStyles, List? borderColors) { + CustomBorderSide left = CustomBorderSide(); + CustomBorderSide top = CustomBorderSide(); + CustomBorderSide right = CustomBorderSide(); + CustomBorderSide bottom = CustomBorderSide(); + if (borderWidths != null && borderWidths.isNotEmpty) { + top.width = expressionToBorderWidth(borderWidths.first); + if (borderWidths.length == 4) { + right.width = expressionToBorderWidth(borderWidths[1]); + bottom.width = expressionToBorderWidth(borderWidths[2]); + left.width = expressionToBorderWidth(borderWidths.last); + } + if (borderWidths.length == 3) { + left.width = expressionToBorderWidth(borderWidths[1]); + right.width = expressionToBorderWidth(borderWidths[1]); + bottom.width = expressionToBorderWidth(borderWidths.last); + } + if (borderWidths.length == 2) { + bottom.width = expressionToBorderWidth(borderWidths.first); + left.width = expressionToBorderWidth(borderWidths.last); + right.width = expressionToBorderWidth(borderWidths.last); + } + if (borderWidths.length == 1) { + bottom.width = expressionToBorderWidth(borderWidths.first); + left.width = expressionToBorderWidth(borderWidths.first); + right.width = expressionToBorderWidth(borderWidths.first); + } + } + if (borderStyles != null && borderStyles.isNotEmpty) { + top.style = expressionToBorderStyle(borderStyles.first); + if (borderStyles.length == 4) { + right.style = expressionToBorderStyle(borderStyles[1]); + bottom.style = expressionToBorderStyle(borderStyles[2]); + left.style = expressionToBorderStyle(borderStyles.last); + } + if (borderStyles.length == 3) { + left.style = expressionToBorderStyle(borderStyles[1]); + right.style = expressionToBorderStyle(borderStyles[1]); + bottom.style = expressionToBorderStyle(borderStyles.last); + } + if (borderStyles.length == 2) { + bottom.style = expressionToBorderStyle(borderStyles.first); + left.style = expressionToBorderStyle(borderStyles.last); + right.style = expressionToBorderStyle(borderStyles.last); + } + if (borderStyles.length == 1) { + bottom.style = expressionToBorderStyle(borderStyles.first); + left.style = expressionToBorderStyle(borderStyles.first); + right.style = expressionToBorderStyle(borderStyles.first); + } + } + if (borderColors != null && borderColors.isNotEmpty) { + top.color = expressionToColor(borderColors.first); + if (borderColors.length == 4) { + right.color = expressionToColor(borderColors[1]); + bottom.color = expressionToColor(borderColors[2]); + left.color = expressionToColor(borderColors.last); + } + if (borderColors.length == 3) { + left.color = expressionToColor(borderColors[1]); + right.color = expressionToColor(borderColors[1]); + bottom.color = expressionToColor(borderColors.last); + } + if (borderColors.length == 2) { + bottom.color = expressionToColor(borderColors.first); + left.color = expressionToColor(borderColors.last); + right.color = expressionToColor(borderColors.last); + } + if (borderColors.length == 1) { + bottom.color = expressionToColor(borderColors.first); + left.color = expressionToColor(borderColors.first); + right.color = expressionToColor(borderColors.first); + } + } + return Border( + top: BorderSide(width: top.width, color: top.color ?? Colors.black, style: top.style), + right: BorderSide(width: right.width, color: right.color ?? Colors.black, style: right.style), + bottom: BorderSide(width: bottom.width, color: bottom.color ?? Colors.black, style: bottom.style), + left: BorderSide(width: left.width, color: left.color ?? Colors.black, style: left.style)); + } + + static double expressionToBorderWidth(css.Expression? value) { + if (value is css.NumberTerm) { + return double.tryParse(value.text) ?? 1.0; + } else if (value is css.PercentageTerm) { + return (double.tryParse(value.text) ?? 400) / 100; + } else if (value is css.EmTerm) { + return double.tryParse(value.text) ?? 1.0; + } else if (value is css.RemTerm) { + return double.tryParse(value.text) ?? 1.0; + } else if (value is css.LengthTerm) { + return double.tryParse(value.text.replaceAll(RegExp(r'\s+(\d+\.\d+)\s+'), '')) ?? 1.0; + } else if (value is css.LiteralTerm) { + switch (value.text) { + case 'thin': + return 2.0; + case 'medium': + return 4.0; + case 'thick': + return 6.0; + } + } + return 4.0; + } + + static BorderStyle expressionToBorderStyle(css.LiteralTerm? value) { + if (value != null && value.text != 'none' && value.text != 'hidden') { + return BorderStyle.solid; + } + return BorderStyle.none; + } + + static Color? expressionToColor(css.Expression? value) { + if (value != null) { + if (value is css.HexColorTerm) { + return stringToColor(value.text); + } else if (value is css.FunctionTerm) { + if (value.text == 'rgba' || value.text == 'rgb') { + return rgbOrRgbaToColor(value.span!.text); + } else if (value.text == 'hsla' || value.text == 'hsl') { + return hslToRgbToColor(value.span!.text); + } + } else if (value is css.LiteralTerm) { + return namedColorToColor(value.text); + } + } + return null; + } + + static TextDirection expressionToDirection(css.Expression value) { + if (value is css.LiteralTerm) { + switch (value.text) { + case 'ltr': + return TextDirection.ltr; + case 'rtl': + return TextDirection.rtl; + } + } + return TextDirection.ltr; + } + + static Display expressionToDisplay(css.Expression value) { + if (value is css.LiteralTerm) { + switch (value.text) { + case 'block': + return Display.block; + case 'inline-block': + return Display.inlineBlock; + case 'inline': + return Display.inline; + case 'list-item': + return Display.listItem; + case 'none': + return Display.none; + } + } + return Display.inline; + } + + static List expressionToFontFeatureSettings(List value) { + List fontFeatures = []; + for (int i = 0; i < value.length; i++) { + css.Expression exp = value[i]; + if (exp is css.LiteralTerm) { + if (exp.text != 'on' && exp.text != 'off' && exp.text != '1' && exp.text != '0') { + if (i < value.length - 1) { + css.Expression nextExp = value[i + 1]; + if (nextExp is css.LiteralTerm && + (nextExp.text == 'on' || nextExp.text == 'off' || nextExp.text == '1' || nextExp.text == '0')) { + fontFeatures.add(FontFeature(exp.text, nextExp.text == 'on' || nextExp.text == '1' ? 1 : 0)); + } else { + fontFeatures.add(FontFeature.enable(exp.text)); + } + } else { + fontFeatures.add(FontFeature.enable(exp.text)); + } + } + } + } + List finalFontFeatures = fontFeatures.toSet().toList(); + return finalFontFeatures; + } + + static FontSize? expressionToFontSize(css.Expression value) { + if (value is css.NumberTerm) { + return FontSize(double.tryParse(value.text) ?? 16, Unit.px); + } else if (value is css.PercentageTerm) { + return FontSize(double.tryParse(value.text) ?? 100, Unit.percent); + } else if (value is css.EmTerm) { + return FontSize(double.tryParse(value.text) ?? 1, Unit.em); + } else if (value is css.LengthTerm) { + return FontSize(double.tryParse(value.text.replaceAll(RegExp(r'\s+(\d+\.\d+)\s+'), '')) ?? 16); + } else if (value is css.LiteralTerm) { + switch (value.text) { + case 'xx-small': + return FontSize.xxSmall; + case 'x-small': + return FontSize.xSmall; + case 'small': + return FontSize.small; + case 'medium': + return FontSize.medium; + case 'large': + return FontSize.large; + case 'x-large': + return FontSize.xLarge; + case 'xx-large': + return FontSize.xxLarge; + } + } + return null; + } + + static FontStyle expressionToFontStyle(css.Expression value) { + if (value is css.LiteralTerm) { + switch (value.text) { + case 'italic': + case 'oblique': + return FontStyle.italic; + } + return FontStyle.normal; + } + return FontStyle.normal; + } + + static FontWeight expressionToFontWeight(css.Expression value) { + if (value is css.NumberTerm) { + switch (value.text) { + case '100': + return FontWeight.w100; + case '200': + return FontWeight.w200; + case '300': + return FontWeight.w300; + case '400': + return FontWeight.w400; + case '500': + return FontWeight.w500; + case '600': + return FontWeight.w600; + case '700': + return FontWeight.w700; + case '800': + return FontWeight.w800; + case '900': + return FontWeight.w900; + } + } else if (value is css.LiteralTerm) { + switch (value.text) { + case 'bold': + return FontWeight.bold; + case 'bolder': + return FontWeight.w900; + case 'lighter': + return FontWeight.w200; + } + return FontWeight.normal; + } + return FontWeight.normal; + } + + static String? expressionToFontFamily(css.Expression value) { + if (value is css.LiteralTerm) return value.text; + return null; + } + + static LineHeight expressionToLineHeight(css.Expression value) { + if (value is css.NumberTerm) { + return LineHeight.number(double.tryParse(value.text)!); + } else if (value is css.PercentageTerm) { + return LineHeight.percent(double.tryParse(value.text)!); + } else if (value is css.EmTerm) { + return LineHeight.em(double.tryParse(value.text)!); + } else if (value is css.RemTerm) { + return LineHeight.rem(double.tryParse(value.text)!); + } else if (value is css.LengthTerm) { + return LineHeight(double.tryParse(value.text.replaceAll(RegExp(r'\s+(\d+\.\d+)\s+'), '')), units: 'length'); + } + return LineHeight.normal; + } + + static ListStyleImage? expressionToListStyleImage(css.UriTerm value) { + return ListStyleImage(value.text); + } + + static ListStyleType? expressionToListStyleType(css.LiteralTerm value) { + return ListStyleType.fromName(value.text); + } + + static Width? expressionToWidth(css.Expression value) { + if ((value is css.LiteralTerm) && value.text == 'auto') { + return Width.auto(); + } else { + final computedValue = expressionToLengthOrPercent(value); + return Width(computedValue.value, computedValue.unit); + } + } + + static Height? expressionToHeight(css.Expression value) { + if ((value is css.LiteralTerm) && value.text == 'auto') { + return Height.auto(); + } else { + final computedValue = expressionToLengthOrPercent(value); + return Height(computedValue.value, computedValue.unit); + } + } + + static Margin? expressionToMargin(css.Expression value) { + if ((value is css.LiteralTerm) && value.text == 'auto') { + return Margin.auto(); + } else { + final computedValue = expressionToLengthOrPercent(value); + return Margin(computedValue.value, computedValue.unit); + } + } + + static Margins expressionToMargins(List? lengths) { + Margin? left; + Margin? right; + Margin? top; + Margin? bottom; + if (lengths != null && lengths.isNotEmpty) { + top = expressionToMargin(lengths.first); + if (lengths.length == 4) { + right = expressionToMargin(lengths[1]); + bottom = expressionToMargin(lengths[2]); + left = expressionToMargin(lengths.last); + } + if (lengths.length == 3) { + left = expressionToMargin(lengths[1]); + right = expressionToMargin(lengths[1]); + bottom = expressionToMargin(lengths.last); + } + if (lengths.length == 2) { + bottom = expressionToMargin(lengths.first); + left = expressionToMargin(lengths.last); + right = expressionToMargin(lengths.last); + } + if (lengths.length == 1) { + bottom = expressionToMargin(lengths.first); + left = expressionToMargin(lengths.first); + right = expressionToMargin(lengths.first); + } + } + return Margins(left: left, right: right, top: top, bottom: bottom); + } + + static List expressionToPadding(List? lengths) { + double? left; + double? right; + double? top; + double? bottom; + if (lengths != null && lengths.isNotEmpty) { + top = expressionToPaddingLength(lengths.first); + if (lengths.length == 4) { + right = expressionToPaddingLength(lengths[1]); + bottom = expressionToPaddingLength(lengths[2]); + left = expressionToPaddingLength(lengths.last); + } + if (lengths.length == 3) { + left = expressionToPaddingLength(lengths[1]); + right = expressionToPaddingLength(lengths[1]); + bottom = expressionToPaddingLength(lengths.last); + } + if (lengths.length == 2) { + bottom = expressionToPaddingLength(lengths.first); + left = expressionToPaddingLength(lengths.last); + right = expressionToPaddingLength(lengths.last); + } + if (lengths.length == 1) { + bottom = expressionToPaddingLength(lengths.first); + left = expressionToPaddingLength(lengths.first); + right = expressionToPaddingLength(lengths.first); + } + } + return [left, right, top, bottom]; + } + + static double? expressionToPaddingLength(css.Expression value) { + if (value is css.NumberTerm) { + return double.tryParse(value.text); + } else if (value is css.EmTerm) { + return double.tryParse(value.text); + } else if (value is css.RemTerm) { + return double.tryParse(value.text); + } else if (value is css.LengthTerm) { + return double.tryParse(value.text.replaceAll(RegExp(r'\s+(\d+\.\d+)\s+'), '')); + } + return null; + } + + static LengthOrPercent expressionToLengthOrPercent(css.Expression value) { + if (value is css.NumberTerm) { + return LengthOrPercent(double.parse(value.text)); + } else if (value is css.EmTerm) { + return LengthOrPercent(double.parse(value.text), Unit.em); + } else if (value is css.LengthTerm) { + double number = double.parse(value.text.replaceAll(RegExp(r'\s+(\d+\.\d+)\s+'), '')); + Unit unit = _unitMap(value.unit); + return LengthOrPercent(number, unit); + } + + //Ignore unparsable input + return LengthOrPercent(0); + } + + static Unit _unitMap(int cssParserUnitToken) { + switch (cssParserUnitToken) { + default: + return Unit.px; + } + } + + static TextAlign expressionToTextAlign(css.Expression value) { + if (value is css.LiteralTerm) { + switch (value.text) { + case 'center': + return TextAlign.center; + case 'left': + return TextAlign.left; + case 'right': + return TextAlign.right; + case 'justify': + return TextAlign.justify; + case 'end': + return TextAlign.end; + case 'start': + return TextAlign.start; + } + } + return TextAlign.start; + } + + static TextDecoration expressionToTextDecorationLine(List value) { + List decorationList = []; + for (css.LiteralTerm? term in value) { + if (term != null) { + switch (term.text) { + case 'overline': + decorationList.add(TextDecoration.overline); + break; + case 'underline': + decorationList.add(TextDecoration.underline); + break; + case 'line-through': + decorationList.add(TextDecoration.lineThrough); + break; + default: + decorationList.add(TextDecoration.none); + break; + } + } + } + if (decorationList.contains(TextDecoration.none)) { + decorationList = [TextDecoration.none]; + } + return TextDecoration.combine(decorationList); + } + + static TextDecorationStyle expressionToTextDecorationStyle(css.LiteralTerm value) { + switch (value.text) { + case 'wavy': + return TextDecorationStyle.wavy; + case 'dotted': + return TextDecorationStyle.dotted; + case 'dashed': + return TextDecorationStyle.dashed; + case 'double': + return TextDecorationStyle.double; + default: + return TextDecorationStyle.solid; + } + } + + static List expressionToTextShadow(List value) { + List shadow = []; + List indices = []; + List> valueList = []; + for (css.Expression e in value) { + if (e is css.OperatorComma) { + indices.add(value.indexOf(e)); + } + } + indices.add(value.length); + int previousIndex = 0; + for (int i in indices) { + valueList.add(value.sublist(previousIndex, i)); + previousIndex = i + 1; + } + for (List list in valueList) { + css.Expression? offsetX; + css.Expression? offsetY; + css.Expression? blurRadius; + css.Expression? color; + int expressionIndex = 0; + for (var element in list) { + if (element is css.HexColorTerm || element is css.FunctionTerm) { + color = element; + } else if (expressionIndex == 0) { + offsetX = element; + expressionIndex++; + } else if (expressionIndex++ == 1) { + offsetY = element; + expressionIndex++; + } else { + blurRadius = element; + } + } + RegExp nonNumberRegex = RegExp(r'\s+(\d+\.\d+)\s+'); + if (offsetX is css.LiteralTerm && offsetY is css.LiteralTerm) { + if (color != null && ExpressionMapping.expressionToColor(color) != null) { + shadow.add(Shadow( + color: expressionToColor(color)!, + offset: Offset(double.tryParse((offsetX).text.replaceAll(nonNumberRegex, ''))!, + double.tryParse((offsetY).text.replaceAll(nonNumberRegex, ''))!), + blurRadius: (blurRadius is css.LiteralTerm) + ? double.tryParse((blurRadius).text.replaceAll(nonNumberRegex, ''))! + : 0.0, + )); + } else { + shadow.add(Shadow( + offset: Offset(double.tryParse((offsetX).text.replaceAll(nonNumberRegex, ''))!, + double.tryParse((offsetY).text.replaceAll(nonNumberRegex, ''))!), + blurRadius: (blurRadius is css.LiteralTerm) + ? double.tryParse((blurRadius).text.replaceAll(nonNumberRegex, ''))! + : 0.0, + )); + } + } + } + List finalShadows = shadow.toSet().toList(); + return finalShadows; + } + + static Color stringToColor(String rawText) { + var text = rawText.replaceFirst('#', ''); + if (text.length == 3) { + text = text.replaceAllMapped( + RegExp(r'[a-f]|\d', caseSensitive: false), (match) => '${match.group(0)}${match.group(0)}'); + } + if (text.length > 6) { + text = '0x$text'; + } else { + text = '0xFF$text'; + } + return Color(int.parse(text)); + } + + static Color? rgbOrRgbaToColor(String text) { + final rgbaText = text.replaceAll(')', '').replaceAll(' ', ''); + try { + final rgbaValues = rgbaText.split(',').map((value) => double.parse(value)).toList(); + if (rgbaValues.length == 4) { + return Color.fromRGBO( + rgbaValues[0].toInt(), + rgbaValues[1].toInt(), + rgbaValues[2].toInt(), + rgbaValues[3], + ); + } else if (rgbaValues.length == 3) { + return Color.fromRGBO( + rgbaValues[0].toInt(), + rgbaValues[1].toInt(), + rgbaValues[2].toInt(), + 1.0, + ); + } + return null; + } catch (e) { + return null; + } + } + + static Color hslToRgbToColor(String text) { + final hslText = text.replaceAll(')', '').replaceAll(' ', ''); + final hslValues = hslText.split(',').toList(); + List parsedHsl = []; + for (var element in hslValues) { + if (element.contains('%') && double.tryParse(element.replaceAll('%', '')) != null) { + parsedHsl.add(double.tryParse(element.replaceAll('%', ''))! * 0.01); + } else { + if (element != hslValues.first && (double.tryParse(element) == null || double.tryParse(element)! > 1)) { + parsedHsl.add(null); + } else { + parsedHsl.add(double.tryParse(element)); + } + } + } + if (parsedHsl.length == 4 && !parsedHsl.contains(null)) { + return HSLColor.fromAHSL(parsedHsl.last!, parsedHsl.first!, parsedHsl[1]!, parsedHsl[2]!).toColor(); + } else if (parsedHsl.length == 3 && !parsedHsl.contains(null)) { + return HSLColor.fromAHSL(1.0, parsedHsl.first!, parsedHsl[1]!, parsedHsl.last!).toColor(); + } else { + return Colors.black; + } + } + + static Color? namedColorToColor(String text) { + String namedColor = + namedColors.keys.firstWhere((element) => element.toLowerCase() == text.toLowerCase(), orElse: () => ''); + if (namedColor != '') { + return stringToColor(namedColors[namedColor]!); + } else { + return null; + } + } +} diff --git a/lib/core/html/src/html_elements.dart b/lib/core/html/src/html_elements.dart new file mode 100644 index 0000000..dd034be --- /dev/null +++ b/lib/core/html/src/html_elements.dart @@ -0,0 +1,207 @@ +export 'interactable_element.dart'; +export 'replaced_element.dart'; +export 'styled_element.dart'; + +class HtmlElements { + static const styledElements = [ + 'abbr', + 'acronym', + 'address', + 'b', + 'bdi', + 'bdo', + 'big', + 'cite', + 'code', + 'data', + 'del', + 'dfn', + 'em', + 'font', + 'i', + 'ins', + 'kbd', + 'mark', + 'q', + 'rt', + 's', + 'samp', + 'small', + 'span', + 'strike', + 'strong', + 'sub', + 'sup', + 'time', + 'tt', + 'u', + 'var', + 'wbr', + + //BLOCK ELEMENTS + 'article', + 'aside', + 'blockquote', + 'body', + 'center', + 'dd', + 'div', + 'dl', + 'dt', + 'figcaption', + 'figure', + 'footer', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'header', + 'hr', + 'html', + 'li', + 'main', + 'nav', + 'noscript', + 'ol', + 'p', + 'pre', + 'section', + 'summary', + 'ul', + ]; + + static const blockElements = [ + 'article', + 'aside', + 'blockquote', + 'body', + 'center', + 'dd', + 'div', + 'dl', + 'dt', + 'figcaption', + 'figure', + 'footer', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'header', + 'hr', + 'html', + 'li', + 'main', + 'nav', + 'noscript', + 'ol', + 'p', + 'pre', + 'section', + 'summary', + 'ul', + ]; + + static const interactableElements = [ + 'a', + ]; + + static const replacedElements = [ + 'br', + 'template', + 'rp', + 'rt', + 'ruby', + ]; + + static const layoutElements = [ + 'details', + 'tr', + 'tbody', + 'tfoot', + 'thead', + ]; + + static const tableCellElements = ['th', 'td']; + + static const tableDefinitionElements = ['col', 'colgroup']; + + static const externalElements = [ + 'audio', + 'iframe', + 'img', + 'math', + 'svg', + 'table', + 'video' + ]; + + static const replacedExternalElements = ['iframe', 'img', 'video', 'audio']; + + static const selectableElements = [ + 'br', + 'a', + 'article', + 'aside', + 'blockquote', + 'body', + 'center', + 'dd', + 'div', + 'dl', + 'dt', + 'figcaption', + 'figure', + 'footer', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'header', + 'hr', + 'html', + 'main', + 'nav', + 'noscript', + 'p', + 'pre', + 'section', + 'summary', + 'abbr', + 'acronym', + 'address', + 'b', + 'bdi', + 'bdo', + 'big', + 'cite', + 'code', + 'data', + 'del', + 'dfn', + 'em', + 'font', + 'i', + 'ins', + 'kbd', + 'mark', + 'q', + 's', + 'samp', + 'small', + 'span', + 'strike', + 'strong', + 'time', + 'tt', + 'u', + 'var', + 'wbr', + ]; +} diff --git a/lib/core/html/src/interactable_element.dart b/lib/core/html/src/interactable_element.dart new file mode 100644 index 0000000..dd02930 --- /dev/null +++ b/lib/core/html/src/interactable_element.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:html/dom.dart' as dom; +import 'package:sonnat/core/html/src/html_elements.dart'; +import 'package:sonnat/core/html/style.dart'; + +/// An [InteractableElement] is a [StyledElement] that takes user gestures (e.g. tap). +class InteractableElement extends StyledElement { + String? href; + + InteractableElement({ + required super.name, + required super.children, + required super.style, + required this.href, + required dom.Node node, + required super.elementId, + }) : super(node: node as dom.Element?); +} + +/// A [Gesture] indicates the type of interaction by a user. +enum Gesture { + tap, +} + +StyledElement parseInteractableElement( + dom.Element element, + List children, +) { + switch (element.localName) { + case 'a': + if (element.attributes.containsKey('href')) { + return InteractableElement( + name: element.localName!, + children: children, + href: element.attributes['href'], + style: Style( + color: Colors.blue, + textDecoration: TextDecoration.underline, + ), + node: element, + elementId: element.id, + ); + } + // When
tag have no href, it must be non clickable and without decoration. + return StyledElement( + name: element.localName!, + children: children, + style: Style(), + node: element, + elementId: element.id, + ); + + /// will never be called, just to suppress missing return warning + default: + return InteractableElement( + name: element.localName!, + children: children, + node: element, + href: '', + style: Style(), + elementId: '[[No ID]]', + ); + } +} diff --git a/lib/core/html/src/layout_element.dart b/lib/core/html/src/layout_element.dart new file mode 100644 index 0000000..b5a837d --- /dev/null +++ b/lib/core/html/src/layout_element.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; +import 'package:html/dom.dart' as dom; +import 'package:sonnat/core/html/html_parser.dart'; +import 'package:sonnat/core/html/src/anchor.dart'; +import 'package:sonnat/core/html/src/css_box_widget.dart'; +import 'package:sonnat/core/html/src/styled_element.dart'; +import 'package:sonnat/core/html/style.dart'; + +abstract class LayoutElement extends StyledElement { + LayoutElement({ + super.name = '[[No Name]]', + required super.children, + String? elementId, + super.node, + }) : super(style: Style(), elementId: elementId ?? '[[No ID]]'); + + Widget? toWidget(RenderContext context); +} + +class TableSectionLayoutElement extends LayoutElement { + TableSectionLayoutElement({ + required super.name, + required super.children, + }); + + @override + Widget toWidget(RenderContext context) { + // Not rendered; TableLayoutElement will instead consume its children + return const Text('TABLE SECTION'); + } +} + +class TableRowLayoutElement extends LayoutElement { + TableRowLayoutElement({ + required super.name, + required super.children, + required super.node, + }); + + @override + Widget toWidget(RenderContext context) { + // Not rendered; TableLayoutElement will instead consume its children + return const Text('TABLE ROW'); + } +} + +class TableCellElement extends StyledElement { + int colspan = 1; + int rowspan = 1; + + TableCellElement({ + required super.name, + required super.elementId, + required super.elementClasses, + required super.children, + required super.style, + required super.node, + }) { + colspan = _parseSpan(this, 'colspan'); + rowspan = _parseSpan(this, 'rowspan'); + } + + static int _parseSpan(StyledElement element, String attributeName) { + final spanValue = element.attributes[attributeName]; + return spanValue == null ? 1 : int.tryParse(spanValue) ?? 1; + } +} + +TableCellElement parseTableCellElement( + dom.Element element, + List children, +) { + final cell = TableCellElement( + name: element.localName!, + elementId: element.id, + elementClasses: element.classes.toList(), + children: children, + node: element, + style: Style(), + ); + if (element.localName == 'th') { + cell.style = Style( + fontWeight: FontWeight.bold, + ); + } + return cell; +} + +class TableStyleElement extends StyledElement { + TableStyleElement({ + required super.name, + required super.children, + required super.style, + required super.node, + }); +} + +TableStyleElement parseTableDefinitionElement( + dom.Element element, + List children, +) { + switch (element.localName) { + case 'colgroup': + case 'col': + return TableStyleElement( + name: element.localName!, + children: children, + node: element, + style: Style(), + ); + default: + return TableStyleElement( + name: '[[No Name]]', + children: children, + node: element, + style: Style(), + ); + } +} + +class DetailsContentElement extends LayoutElement { + List elementList; + + DetailsContentElement({ + required super.name, + required super.children, + required dom.Element node, + required this.elementList, + }) : super(node: node, elementId: node.id); + + @override + Widget toWidget(RenderContext context) { + List? childrenList = children.map((tree) => context.parser.parseTree(context, tree)).toList(); + List toRemove = []; + for (InlineSpan child in childrenList) { + if (child is TextSpan && child.text != null && child.text!.trim().isEmpty) { + toRemove.add(child); + } + } + for (InlineSpan child in toRemove) { + childrenList.remove(child); + } + InlineSpan? firstChild = childrenList.isNotEmpty == true ? childrenList.first : null; + return ExpansionTile( + key: AnchorKey.of(context.parser.key, this), + expandedAlignment: Alignment.centerLeft, + title: elementList.isNotEmpty == true && elementList.first.localName == 'summary' + ? CssBoxWidget.withInlineSpanChildren( + children: firstChild == null ? [] : [firstChild], + style: style, + ) + : const Text('Details'), + children: [ + CssBoxWidget.withInlineSpanChildren( + children: getChildren(childrenList, context, + elementList.isNotEmpty == true && elementList.first.localName == 'summary' ? firstChild : null), + style: style, + ), + ]); + } + + List getChildren(List children, RenderContext context, InlineSpan? firstChild) { + if (firstChild != null) children.removeAt(0); + return children; + } +} + +class EmptyLayoutElement extends LayoutElement { + EmptyLayoutElement({required super.name}) + : super( + children: [], + ); + + @override + Widget? toWidget(context) => null; +} + +LayoutElement parseLayoutElement( + dom.Element element, + List children, +) { + switch (element.localName) { + case 'details': + if (children.isEmpty) { + return EmptyLayoutElement(name: 'empty'); + } + return DetailsContentElement( + node: element, + name: element.localName!, + children: children, + elementList: element.children, + ); + case 'thead': + case 'tbody': + case 'tfoot': + return TableSectionLayoutElement( + name: element.localName!, + children: children, + ); + case 'tr': + return TableRowLayoutElement( + name: element.localName!, + children: children, + node: element, + ); + default: + return EmptyLayoutElement(name: '[[No Name]]'); + } +} diff --git a/lib/core/html/src/replaced_element.dart b/lib/core/html/src/replaced_element.dart new file mode 100644 index 0000000..c69ba8e --- /dev/null +++ b/lib/core/html/src/replaced_element.dart @@ -0,0 +1,167 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:html/dom.dart' as dom; +import 'package:sonnat/core/html/html_parser.dart'; +import 'package:sonnat/core/html/src/anchor.dart'; +import 'package:sonnat/core/html/src/css_box_widget.dart'; +import 'package:sonnat/core/html/src/styled_element.dart'; +import 'package:sonnat/core/html/style.dart'; + +/// A [ReplacedElement] is a type of [StyledElement] that does not require its [children] to be rendered. +/// +/// A [ReplacedElement] may use its children nodes to determine relevant information +/// (e.g.