Browse Source

add html viewer

fix_bug
mohsen zamani 2 years ago
parent
commit
e4f5daabdc
  1. 11
      android/app/build.gradle
  2. 22
      android/app/src/main/AndroidManifest.xml
  3. 4
      android/build.gradle
  4. 4007
      assets/lottie/loading.json
  5. 18
      lib/core/extensions/number_extension.dart
  6. 489
      lib/core/html/custom_render.dart
  7. 326
      lib/core/html/flutter_html.dart
  8. 877
      lib/core/html/html_parser.dart
  9. 251
      lib/core/html/html_viewer.dart
  10. 45
      lib/core/html/src/anchor.dart
  11. 734
      lib/core/html/src/css_box_widget.dart
  12. 1172
      lib/core/html/src/css_parser.dart
  13. 207
      lib/core/html/src/html_elements.dart
  14. 64
      lib/core/html/src/interactable_element.dart
  15. 209
      lib/core/html/src/layout_element.dart
  16. 167
      lib/core/html/src/replaced_element.dart
  17. 30
      lib/core/html/src/style/fontsize.dart
  18. 64
      lib/core/html/src/style/length.dart
  19. 24
      lib/core/html/src/style/lineheight.dart
  20. 73
      lib/core/html/src/style/margin.dart
  21. 35
      lib/core/html/src/style/marker.dart
  22. 15
      lib/core/html/src/style/size.dart
  23. 411
      lib/core/html/src/styled_element.dart
  24. 89
      lib/core/html/src/utils.dart
  25. 220
      lib/core/html/string_proccess.dart
  26. 551
      lib/core/html/style.dart
  27. 11
      lib/core/language/language_cubit.dart
  28. 9
      lib/core/language/languages.dart
  29. 1
      lib/core/language/translator.dart
  30. 74
      lib/core/player_widgets/audio_player.dart
  31. 67
      lib/core/player_widgets/video_player.dart
  32. 144
      lib/core/theme/app_colors.dart
  33. 130
      lib/core/theme/app_theme.dart
  34. 4
      lib/core/theme/cubit/theme_cubit.dart
  35. 8
      lib/core/theme/panel_colors.dart
  36. 4
      lib/core/theme/panel_theme.dart
  37. 6
      lib/core/theme/panel_typography.dart
  38. 37
      lib/core/theme/reader_theme.dart
  39. 23
      lib/core/utils/app_utils.dart
  40. 86
      lib/core/utils/url_launcher.dart
  41. 45
      lib/core/utils/utilities.dart
  42. 52
      lib/core/widgets/custom_rich_text.dart
  43. 54
      lib/core/widgets/global_loading.dart
  44. 63
      lib/core/widgets/show_image_widget.dart
  45. 17
      lib/features/main/main_screen.dart
  46. 2
      lib/features/main/widget/main_item_widget.dart
  47. 13
      lib/features/posts/screen/posts_screen.dart
  48. 2
      lib/features/posts/widgets/filter_item_widget.dart
  49. 153
      lib/features/single_post/screen/single_post_screen.dart
  50. 120
      lib/features/single_post/view_models/post.dart
  51. 31
      lib/features/single_post/view_models/thumbnail.dart
  52. 278
      pubspec.lock
  53. 13
      pubspec.yaml

11
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"
}

22
android/app/src/main/AndroidManifest.xml

@ -1,31 +1,25 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:label="sonnat"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher">
android:icon="@mipmap/ic_launcher"
android:label="sonnat">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />

4
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"
}
}

4007
assets/lottie/loading.json
File diff suppressed because it is too large
View File

18
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();
}

489
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<String> schemas = const ['https', 'http'],
List<String>? 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<InlineSpan> Function())? inlineSpan;
final Widget Function(RenderContext, List<InlineSpan> 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<TextSpan> Function()) textSpan;
SelectableCustomRender.fromTextSpan({
required this.textSpan,
}) : super.inlineSpan(inlineSpan: null);
}
CustomRender blockElementRender({Style? style, List<InlineSpan>? children}) =>
CustomRender.inlineSpan(inlineSpan: (context, buildChildren) {
if (context.parser.selectable) {
return TextSpan(
style: context.style.generateTextStyle(),
children: (children as List<TextSpan>?) ??
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<InlineSpan>? 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<String, String>? 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<Size> 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<String, String>();
final widget = FutureBuilder<Size>(
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<InlineSpan>? 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<InlineSpan>? 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<InlineSpan>? 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<CustomRenderMatcher, CustomRender> 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('^(?<scheme>data):(?<mime>image\\/[\\w\\+\\-\\.]+)(?<encoding>;base64)?\\,(?<data>.*)');
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<String, String> attributes) {
return attributes['src'];
}
String? _alt(Map<String, String> attributes) {
return attributes['alt'];
}
double? _height(Map<String, String> attributes) {
final heightString = attributes['height'];
return heightString == null ? heightString as double? : double.tryParse(heightString);
}
double? _width(Map<String, String> attributes) {
final widthString = attributes['width'];
return widthString == null ? widthString as double? : double.tryParse(widthString);
}
double _aspectRatio(Map<String, String> attributes, AsyncSnapshot<Size> 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));
}

326
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<String> tagsList;
/// Either return a custom widget for specific node types or return null to
/// fallback to the default rendering.
final Map<CustomRenderMatcher, CustomRender> customRenders;
/// An API that allows you to override the default style for any HTML element
final Map<String, Style> style;
static List<String> get tags => List<String>.from(HtmlElements.styledElements)
..addAll(HtmlElements.interactableElements)
..addAll(HtmlElements.replacedElements)
..addAll(HtmlElements.layoutElements)
..addAll(HtmlElements.tableCellElements)
..addAll(HtmlElements.tableDefinitionElements)
..addAll(HtmlElements.externalElements);
@override
State<StatefulWidget> createState() => _HtmlState();
}
class _HtmlState extends State<Html> {
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 (`<a href>`)
/// 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<String> tagsList;
/// An API that allows you to override the default style for any HTML element
final Map<String, Style> 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<CustomRenderMatcher, SelectableCustomRender> customRenders;
static List<String> get tags =>
List<String>.from(HtmlElements.selectableElements);
@override
State<StatefulWidget> createState() => _SelectableHtmlState();
}
class _SelectableHtmlState extends State<SelectableHtml> {
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,
),
);
}
}

877
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<String, String> attributes,
dom.Element? element,
);
typedef OnCssParseError = String? Function(
String css,
List<cssparser.Message> 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<String, Style> style;
final Map<CustomRenderMatcher, CustomRender> customRenders;
final List<String> tagsList;
final OnTap? internalOnAnchorTap;
final Html? root;
final TextSelectionControls? selectionControls;
final ScrollPhysics? scrollPhysics;
final Map<String, Size> 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<CustomRenderMatcher> customRenderMatchers,
List<String> tagsList,
BuildContext context,
HtmlParser parser,
) {
StyledElement tree = StyledElement(
name: '[Tree Root]',
children: <StyledElement>[],
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<CustomRenderMatcher> customRenderMatchers,
List<String> tagsList,
BuildContext context,
HtmlParser parser,
) {
List<StyledElement> children = <StyledElement>[];
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<String, Map<String, List<css.Expression>>> _getExternalCssDeclarations(
List<dom.Element> 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<String, Map<String, List<css.Expression>>> 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<String, Style> 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<String, Style> 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<String, Style> style, OnCssParseError? onCssParseError) {
Map<String, Map<String, List<css.Expression>>> 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<InlineSpan> buildChildren() => tree.children.map((tree) => parseTree(newContext, tree)).toList();
if (newContext.parser.selectable && customRenders[entry] is SelectableCustomRender) {
List<TextSpan> 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<bool> 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 <br> 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<Counter>? 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<StyledElement> toRemove = <StyledElement>[];
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,
});
}

251
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,
),
);
}
}

45
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<AnchorKey> _registry = <AnchorKey>{};
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}';
}
}

734
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<InlineSpan> 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<InlineSpan> 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<InlineSpan> 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<RenderBox, CSSBoxParentData>,
RenderBoxContainerDefaultsMixin<RenderBox, CSSBoxParentData> {
_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<RenderBox> {}
class _Sizes {
final Size parentSize;
final Size childSize;
const _Sizes(this.parentSize, this.childSize);
}

1172
lib/core/html/src/css_parser.dart
File diff suppressed because it is too large
View File

207
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',
];
}

64
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<StyledElement> 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 <a> 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]]',
);
}
}

209
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<StyledElement> 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<StyledElement> 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<dom.Element> 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<InlineSpan>? childrenList = children.map((tree) => context.parser.parseTree(context, tree)).toList();
List<InlineSpan> 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<InlineSpan> getChildren(List<InlineSpan> 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<StyledElement> 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]]');
}
}

167
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. <video>'s <source> tags), but the children nodes will not be saved as [children].
abstract class ReplacedElement extends StyledElement {
PlaceholderAlignment alignment;
ReplacedElement({
required super.name,
required super.style,
required super.elementId,
List<StyledElement>? children,
super.node,
this.alignment = PlaceholderAlignment.aboveBaseline,
}) : super(children: children ?? []);
static List<String?> parseMediaSources(List<dom.Element> elements) {
return elements.where((element) => element.localName == 'source').map((element) {
return element.attributes['src'];
}).toList();
}
Widget? toWidget(RenderContext context);
}
/// [TextContentElement] is a [ContentElement] with plaintext as its content.
class TextContentElement extends ReplacedElement {
String? text;
dom.Node? node;
TextContentElement({
required super.style,
required this.text,
this.node,
dom.Element? element,
}) : super(name: '[text]', node: element, elementId: '[[No ID]]');
@override
String toString() {
return "\"${text!.replaceAll("\n", "\\n")}\"";
}
@override
Widget? toWidget(context) => null;
}
class EmptyContentElement extends ReplacedElement {
EmptyContentElement({super.name = 'empty'}) : super(style: Style(), elementId: '[[No ID]]');
@override
Widget? toWidget(context) => null;
}
class RubyElement extends ReplacedElement {
@override
dom.Element element;
RubyElement({
required this.element,
required List<StyledElement> super.children,
super.name = 'ruby',
}) : super(alignment: PlaceholderAlignment.middle, style: Style(), elementId: element.id);
@override
Widget toWidget(RenderContext context) {
StyledElement? node;
List<Widget> widgets = <Widget>[];
final rubySize = context.parser.style['rt']?.fontSize?.value ?? max(9.0, context.style.fontSize!.value / 2);
final rubyYPos = rubySize + rubySize / 2;
List<StyledElement> children = [];
context.tree.children.forEachIndexed((index, element) {
if (!((element is TextContentElement) &&
(element.text ?? '').trim().isEmpty &&
index > 0 &&
index + 1 < context.tree.children.length &&
context.tree.children[index - 1] is! TextContentElement &&
context.tree.children[index + 1] is! TextContentElement)) {
children.add(element);
}
});
for (var c in children) {
if (c.name == 'rt' && node != null) {
final widget = Stack(
alignment: Alignment.center,
children: <Widget>[
Container(
alignment: Alignment.bottomCenter,
child: Center(
child: Transform(
transform: Matrix4.translationValues(0, -(rubyYPos), 0),
child: CssBoxWidget(
style: c.style,
child: Text(
c.element!.innerHtml,
style: c.style.generateTextStyle().copyWith(fontSize: rubySize),
),
),
),
),
),
CssBoxWidget(
style: context.style,
child: node is TextContentElement
? Text(
node.text?.trim() ?? '',
style: context.style.generateTextStyle(),
)
: RichText(text: context.parser.parseTree(context, node)),
),
],
);
widgets.add(widget);
} else {
node = c;
}
}
return Padding(
padding: EdgeInsets.only(top: rubySize),
child: Wrap(
key: AnchorKey.of(context.parser.key, this),
runSpacing: rubySize,
children: widgets
.map((e) => Row(
crossAxisAlignment: CrossAxisAlignment.end,
textBaseline: TextBaseline.alphabetic,
mainAxisSize: MainAxisSize.min,
children: [e],
))
.toList(),
),
);
}
}
ReplacedElement parseReplacedElement(
dom.Element element,
List<StyledElement> children,
) {
switch (element.localName) {
case 'br':
return TextContentElement(
text: '\n',
style: Style(whiteSpace: WhiteSpace.pre),
element: element,
node: element,
);
case 'ruby':
return RubyElement(
element: element,
children: children,
);
default:
return EmptyContentElement(name: element.localName == null ? '[[No Name]]' : element.localName!);
}
}

30
lib/core/html/src/style/fontsize.dart

@ -0,0 +1,30 @@
import 'length.dart';
class FontSize extends LengthOrPercent {
FontSize(super.size, [super.unit]);
static final xxSmall = FontSize(7.875);
static final xSmall = FontSize(8.75);
static final small = FontSize(11.375);
static final medium = FontSize(14.0);
static final large = FontSize(15.75);
static final xLarge = FontSize(21.0);
static final xxLarge = FontSize(28.0);
static final smaller = FontSize(83, Unit.percent);
static final larger = FontSize(120, Unit.percent);
static FontSize? inherit(FontSize? parent, FontSize? child) {
if (child != null && parent != null) {
if (child.unit == Unit.em) {
return FontSize(child.value * parent.value);
} else if (child.unit == Unit.percent) {
return FontSize(child.value / 100.0 * parent.value);
}
return child;
}
return parent;
}
double get emValue => value;
}

64
lib/core/html/src/style/length.dart

@ -0,0 +1,64 @@
/// These are the base unit types
enum UnitType {
percent,
length,
auto,
lengthPercent(children: [UnitType.length, UnitType.percent]),
lengthPercentAuto(
children: [UnitType.length, UnitType.percent, UnitType.auto]);
final List<UnitType> children;
const UnitType({this.children = const []});
bool matches(UnitType other) {
return this == other || children.contains(other);
}
}
/// A Unit represents a CSS unit
enum Unit {
//ch,
em(UnitType.length),
//ex,
percent(UnitType.percent),
px(UnitType.length),
rem(UnitType.length),
//Q,
//vh,
//vw,
auto(UnitType.auto);
const Unit(this.unitType);
final UnitType unitType;
}
/// Represents a CSS dimension https://drafts.csswg.org/css-values/#dimensions
abstract class Dimension {
double value;
Unit unit;
Dimension(this.value, this.unit, UnitType dimensionUnitType)
: assert(dimensionUnitType.matches(unit.unitType),
"This Dimension was given a Unit that isn't specified.");
}
/// This dimension takes a value with a length unit such as px or em. Note that
/// these can be fixed or relative (but they must not be a percent)
class Length extends Dimension {
Length(double value, [Unit unit = Unit.px])
: super(value, unit, UnitType.length);
}
/// This dimension takes a value with a length-percent unit such as px or em
/// or %. Note that these can be fixed or relative (but they must not be a
/// percent)
class LengthOrPercent extends Dimension {
LengthOrPercent(double value, [Unit unit = Unit.px])
: super(value, unit, UnitType.lengthPercent);
}
class AutoOrLengthOrPercent extends Dimension {
AutoOrLengthOrPercent(double value, [Unit unit = Unit.px])
: super(value, unit, UnitType.lengthPercentAuto);
}

24
lib/core/html/src/style/lineheight.dart

@ -0,0 +1,24 @@
class LineHeight {
final double? size;
final String units;
const LineHeight(this.size, {this.units = ''});
factory LineHeight.percent(double percent) {
return LineHeight(percent / 100.0 * 1.2, units: '%');
}
factory LineHeight.em(double em) {
return LineHeight(em * 1.2, units: 'em');
}
factory LineHeight.rem(double rem) {
return LineHeight(rem * 1.2, units: 'rem');
}
factory LineHeight.number(double num) {
return LineHeight(num * 1.2, units: 'number');
}
static const normal = LineHeight(1.2);
}

73
lib/core/html/src/style/margin.dart

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:sonnat/core/html/src/style/length.dart';
class Margin extends AutoOrLengthOrPercent {
Margin(double value, [Unit? unit = Unit.px]) : super(value, unit ?? Unit.px);
Margin.auto() : super(0, Unit.auto);
Margin.zero() : super(0, Unit.px);
}
class Margins {
final Margin? left;
final Margin? right;
final Margin? top;
final Margin? bottom;
const Margins({this.left, this.right, this.top, this.bottom});
/// Auto margins already have a "value" of zero so can be considered collapsed.
Margins collapse() => Margins(
left: left?.unit == Unit.auto ? left : Margin(0, Unit.px),
right: right?.unit == Unit.auto ? right : Margin(0, Unit.px),
top: top?.unit == Unit.auto ? top : Margin(0, Unit.px),
bottom: bottom?.unit == Unit.auto ? bottom : Margin(0, Unit.px),
);
Margins copyWith(
{Margin? left, Margin? right, Margin? top, Margin? bottom}) =>
Margins(
left: left ?? this.left,
right: right ?? this.right,
top: top ?? this.top,
bottom: bottom ?? this.bottom,
);
Margins copyWithEdge(
{double? left, double? right, double? top, double? bottom}) =>
Margins(
left: left != null ? Margin(left, this.left?.unit) : this.left,
right: right != null ? Margin(right, this.right?.unit) : this.right,
top: top != null ? Margin(top, this.top?.unit) : this.top,
bottom:
bottom != null ? Margin(bottom, this.bottom?.unit) : this.bottom,
);
// bool get isAutoHorizontal => (left is MarginAuto) || (right is MarginAuto);
/// Analogous to [EdgeInsets.zero]
static Margins get zero => Margins.all(0);
/// Analogous to [EdgeInsets.all]
Margins.all(double value, {Unit? unit})
: left = Margin(value, unit),
right = Margin(value, unit),
top = Margin(value, unit),
bottom = Margin(value, unit);
/// Analogous to [EdgeInsets.only]
Margins.only(
{double? left, double? right, double? top, double? bottom, Unit? unit})
: left = Margin(left ?? 0, unit),
right = Margin(right ?? 0, unit),
top = Margin(top ?? 0, unit),
bottom = Margin(bottom ?? 0, unit);
/// Analogous to [EdgeInsets.symmetric]
Margins.symmetric({double? horizontal, double? vertical, Unit? unit})
: left = Margin(horizontal ?? 0, unit),
right = Margin(horizontal ?? 0, unit),
top = Margin(vertical ?? 0, unit),
bottom = Margin(vertical ?? 0, unit);
}

35
lib/core/html/src/style/marker.dart

@ -0,0 +1,35 @@
import 'package:sonnat/core/html/style.dart';
class Marker {
final Content content;
Style? style;
Marker({
this.content = Content.normal,
this.style,
});
}
class Content {
final String? replacementContent;
final bool _normal;
final bool display;
const Content(this.replacementContent)
: _normal = false,
display = true;
const Content._normal()
: _normal = true,
display = true,
replacementContent = null;
const Content._none()
: _normal = false,
display = false,
replacementContent = null;
static const Content none = Content._none();
static const Content normal = Content._normal();
bool get isNormal => _normal;
}

15
lib/core/html/src/style/size.dart

@ -0,0 +1,15 @@
import 'package:sonnat/core/html/src/style/length.dart';
class Width extends AutoOrLengthOrPercent {
Width(super.value, [super.unit = Unit.px])
: assert(value >= 0, 'Width value must be non-negative');
Width.auto() : super(0, Unit.auto);
}
class Height extends AutoOrLengthOrPercent {
Height(super.value, [super.unit = Unit.px])
: assert(value >= 0, 'Height value must be non-negative');
Height.auto() : super(0, Unit.auto);
}

411
lib/core/html/src/styled_element.dart

@ -0,0 +1,411 @@
import 'dart:collection';
import 'package:flutter/material.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/src/query_selector.dart';
import 'package:list_counter/list_counter.dart';
import 'package:sonnat/core/html/src/css_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/margin.dart';
import 'package:sonnat/core/html/style.dart';
/// A [StyledElement] applies a style to all of its children.
class StyledElement {
final String name;
final String elementId;
final List<String> elementClasses;
List<StyledElement> children;
Style style;
final dom.Element? _node;
final ListQueue<Counter> counters = ListQueue<Counter>();
StyledElement({
this.name = '[[No name]]',
this.elementId = '[[No ID]]',
this.elementClasses = const [],
required this.children,
required this.style,
required dom.Element? node,
}) : _node = node;
bool matchesSelector(String selector) => (_node != null && matches(_node!, selector)) || name == selector;
Map<String, String> get attributes =>
_node?.attributes.map((key, value) {
return MapEntry(key.toString(), value);
}) ??
<String, String>{};
dom.Element? get element => _node;
@override
String toString() {
String selfData =
"[$name] ${children.length} ${elementClasses.isNotEmpty == true ? 'C:${elementClasses.toString()}' : ''}${elementId.isNotEmpty == true ? 'ID: $elementId' : ''}";
for (var child in children) {
selfData += ('\n${child.toString()}').replaceAll(RegExp('^', multiLine: true), '-');
}
return selfData;
}
}
StyledElement parseStyledElement(
dom.Element element,
List<StyledElement> children,
) {
StyledElement styledElement = StyledElement(
name: element.localName!,
elementId: element.id,
elementClasses: element.classes.toList(),
children: children,
node: element,
style: Style(),
);
switch (element.localName) {
case 'abbr':
case 'acronym':
styledElement.style = Style(
textDecoration: TextDecoration.underline,
textDecorationStyle: TextDecorationStyle.dotted,
);
break;
case 'address':
continue italics;
case 'article':
styledElement.style = Style(
display: Display.block,
);
break;
case 'aside':
styledElement.style = Style(
display: Display.block,
);
break;
bold:
case 'b':
styledElement.style = Style(
fontWeight: FontWeight.bold,
);
break;
case 'bdo':
TextDirection textDirection =
((element.attributes['dir'] ?? 'ltr') == 'rtl') ? TextDirection.rtl : TextDirection.ltr;
styledElement.style = Style(
direction: textDirection,
);
break;
case 'big':
styledElement.style = Style(
fontSize: FontSize.larger,
);
break;
case 'blockquote':
if (element.parent!.localName == 'blockquote') {
styledElement.style = Style(
margin: Margins.only(left: 40.0, right: 40.0, bottom: 14.0),
display: Display.block,
);
} else {
styledElement.style = Style(
margin: Margins.symmetric(horizontal: 40.0, vertical: 14.0),
display: Display.block,
);
}
break;
case 'body':
styledElement.style = Style(
margin: Margins.all(8.0),
display: Display.block,
);
break;
case 'center':
styledElement.style = Style(
alignment: Alignment.center,
display: Display.block,
);
break;
case 'cite':
continue italics;
monospace:
case 'code':
styledElement.style = Style(
fontFamily: 'Monospace',
);
break;
case 'dd':
styledElement.style = Style(
margin: Margins.only(left: 40.0),
display: Display.block,
);
break;
strikeThrough:
case 'del':
styledElement.style = Style(
textDecoration: TextDecoration.lineThrough,
);
break;
case 'dfn':
continue italics;
case 'div':
styledElement.style = Style(
margin: Margins.all(0),
display: Display.block,
);
break;
case 'dl':
styledElement.style = Style(
margin: Margins.symmetric(vertical: 14.0),
display: Display.block,
);
break;
case 'dt':
styledElement.style = Style(
display: Display.block,
);
break;
case 'em':
continue italics;
case 'figcaption':
styledElement.style = Style(
display: Display.block,
);
break;
case 'figure':
styledElement.style = Style(
margin: Margins.symmetric(vertical: 14.0, horizontal: 40.0),
display: Display.block,
);
break;
case 'footer':
styledElement.style = Style(
display: Display.block,
);
break;
case 'font':
styledElement.style = Style(
color: element.attributes['color'] != null
? element.attributes['color']!.startsWith('#')
? ExpressionMapping.stringToColor(element.attributes['color']!)
: ExpressionMapping.namedColorToColor(element.attributes['color']!)
: null,
fontFamily: element.attributes['face']?.split(',').first,
fontSize: element.attributes['size'] != null ? numberToFontSize(element.attributes['size']!) : null,
);
break;
case 'h1':
styledElement.style = Style(
fontSize: FontSize(2, Unit.em),
fontWeight: FontWeight.bold,
margin: Margins.symmetric(vertical: 0.67, unit: Unit.em),
display: Display.block,
);
break;
case 'h2':
styledElement.style = Style(
fontSize: FontSize(1.5, Unit.em),
fontWeight: FontWeight.bold,
margin: Margins.symmetric(vertical: 0.83, unit: Unit.em),
display: Display.block,
);
break;
case 'h3':
styledElement.style = Style(
fontSize: FontSize(1.17, Unit.em),
fontWeight: FontWeight.bold,
margin: Margins.symmetric(vertical: 1, unit: Unit.em),
display: Display.block,
);
break;
case 'h4':
styledElement.style = Style(
fontWeight: FontWeight.bold,
margin: Margins.symmetric(vertical: 1.33, unit: Unit.em),
display: Display.block,
);
break;
case 'h5':
styledElement.style = Style(
fontSize: FontSize(0.83, Unit.em),
fontWeight: FontWeight.bold,
margin: Margins.symmetric(vertical: 1.67, unit: Unit.em),
display: Display.block,
);
break;
case 'h6':
styledElement.style = Style(
fontSize: FontSize(0.67, Unit.em),
fontWeight: FontWeight.bold,
margin: Margins.symmetric(vertical: 2.33, unit: Unit.em),
display: Display.block,
);
break;
case 'header':
styledElement.style = Style(
display: Display.block,
);
break;
case 'hr':
styledElement.style = Style(
margin: Margins(
top: Margin(0.5, Unit.em),
bottom: Margin(0.5, Unit.em),
left: Margin.auto(),
right: Margin.auto(),
),
border: Border.all(),
display: Display.block,
);
break;
case 'html':
styledElement.style = Style(
display: Display.block,
);
break;
italics:
case 'i':
styledElement.style = Style(
fontStyle: FontStyle.italic,
);
break;
case 'ins':
continue underline;
case 'kbd':
continue monospace;
case 'li':
styledElement.style = Style(
display: Display.listItem,
);
break;
case 'main':
styledElement.style = Style(
display: Display.block,
);
break;
case 'mark':
styledElement.style = Style(
color: Colors.black,
backgroundColor: Colors.yellow,
);
break;
case 'nav':
styledElement.style = Style(
display: Display.block,
);
break;
case 'noscript':
styledElement.style = Style(
display: Display.block,
);
break;
case 'ol':
case 'ul':
styledElement.style = Style(
display: Display.block,
listStyleType: element.localName == 'ol' ? ListStyleType.decimal : ListStyleType.disc,
padding: const EdgeInsets.only(left: 40),
);
break;
case 'p':
styledElement.style = Style(
margin: Margins.symmetric(vertical: 1, unit: Unit.em),
display: Display.block,
);
break;
case 'pre':
styledElement.style = Style(
fontFamily: 'monospace',
margin: Margins.symmetric(vertical: 14.0),
whiteSpace: WhiteSpace.pre,
display: Display.block,
);
break;
case 'q':
styledElement.style = Style(
before: '"',
after: '"',
);
break;
case 's':
continue strikeThrough;
case 'samp':
continue monospace;
case 'section':
styledElement.style = Style(
display: Display.block,
);
break;
case 'small':
styledElement.style = Style(
fontSize: FontSize.smaller,
);
break;
case 'strike':
continue strikeThrough;
case 'strong':
continue bold;
case 'sub':
styledElement.style = Style(
fontSize: FontSize.smaller,
verticalAlign: VerticalAlign.sub,
);
break;
case 'sup':
styledElement.style = Style(
fontSize: FontSize.smaller,
verticalAlign: VerticalAlign.sup,
);
break;
case 'tt':
continue monospace;
underline:
case 'u':
styledElement.style = Style(
textDecoration: TextDecoration.underline,
);
break;
case 'var':
continue italics;
}
return styledElement;
}
typedef ListCharacter = String Function(int i);
FontSize numberToFontSize(String num) {
switch (num) {
case '1':
return FontSize.xxSmall;
case '2':
return FontSize.xSmall;
case '3':
return FontSize.small;
case '4':
return FontSize.medium;
case '5':
return FontSize.large;
case '6':
return FontSize.xLarge;
case '7':
return FontSize.xxLarge;
}
if (num.startsWith('+')) {
final relativeNum = double.tryParse(num.substring(1)) ?? 0;
return numberToFontSize((3 + relativeNum).toString());
}
if (num.startsWith('-')) {
final relativeNum = double.tryParse(num.substring(1)) ?? 0;
return numberToFontSize((3 - relativeNum).toString());
}
return FontSize.medium;
}
extension DeepCopy on ListQueue<Counter> {
ListQueue<Counter> deepCopy() {
return ListQueue<Counter>.from(map((counter) {
return Counter(counter.name, counter.value);
}));
}
}

89
lib/core/html/src/utils.dart

@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:sonnat/core/html/style.dart';
Map<String, String> namedColors = {
'White': '#FFFFFF',
'Silver': '#C0C0C0',
'Gray': '#808080',
'Black': '#000000',
'Red': '#FF0000',
'Maroon': '#800000',
'Yellow': '#FFFF00',
'Olive': '#808000',
'Lime': '#00FF00',
'Green': '#008000',
'Aqua': '#00FFFF',
'Teal': '#008080',
'Blue': '#0000FF',
'Navy': '#000080',
'Fuchsia': '#FF00FF',
'Purple': '#800080',
};
class Context<T> {
T data;
Context(this.data);
}
// This class is a workaround so that both an image
// and a link can detect taps at the same time.
class MultipleTapGestureDetector extends InheritedWidget {
final void Function()? onTap;
const MultipleTapGestureDetector({
super.key,
required super.child,
required this.onTap,
});
static MultipleTapGestureDetector? of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<MultipleTapGestureDetector>();
}
@override
bool updateShouldNotify(MultipleTapGestureDetector oldWidget) => false;
}
class CustomBorderSide {
CustomBorderSide({
this.color = const Color(0xFF000000),
this.width = 1.0,
this.style = BorderStyle.none,
}) : assert(width >= 0.0);
Color? color;
double width;
BorderStyle style;
}
extension TextTransformUtil on String? {
String? transformed(TextTransform? transform) {
if (this == null) return null;
if (transform == TextTransform.uppercase) {
return this!.toUpperCase();
} else if (transform == TextTransform.lowercase) {
return this!.toLowerCase();
} else if (transform == TextTransform.capitalize) {
final stringBuffer = StringBuffer();
var capitalizeNext = true;
for (final letter in this!.toLowerCase().codeUnits) {
// UTF-16: A-Z => 65-90, a-z => 97-122.
if (capitalizeNext && letter >= 97 && letter <= 122) {
stringBuffer.writeCharCode(letter - 32);
capitalizeNext = false;
} else {
// UTF-16: 32 == space, 46 == period
if (letter == 32 || letter == 46) capitalizeNext = true;
stringBuffer.writeCharCode(letter);
}
}
return stringBuffer.toString();
} else {
return this;
}
}
}

220
lib/core/html/string_proccess.dart

@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:sonnat/core/extensions/number_extension.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/custom_rich_text.dart';
class QHeaderTextShower extends StatelessWidget {
final String title;
final bool hasFullWidth;
final double fontSizeFactor;
final String? searchHighLight;
const QHeaderTextShower({
super.key,
required this.title,
this.hasFullWidth = true,
this.fontSizeFactor = 1,
this.searchHighLight,
});
@override
Widget build(BuildContext context) {
return Container(
width: hasFullWidth ? 1.sw : null,
margin: Utils.instance.singleMargin(top: 7, bottom: 7),
child: Container(
padding: const EdgeInsets.all(8),
decoration: const BoxDecoration(
color: AppColors.ahkamBlue2,
borderRadius: BorderRadius.all(
Radius.circular(8),
),
),
child: HighlightRichText(
text: title.replaceAll(':', '').trim(),
highlight: searchHighLight,
style: AppTheme.instance.fontCreator(fontSizeFactor * 15, FontWeights.bold, AppColors.white),
textAlign: TextAlign.center,
),
),
);
}
}
class QAnswerShower extends StatelessWidget {
final String title;
final bool hasFullWidth;
final ReaderTheme? theme;
final double fontSizeFactor;
final String? searchHighLight;
const QAnswerShower({
super.key,
required this.title,
this.hasFullWidth = true,
this.theme = ReaderTheme.light,
this.fontSizeFactor = 1,
this.searchHighLight,
});
@override
Widget build(BuildContext context) {
return Container(
width: hasFullWidth ? 1.sw : null,
margin: Utils.instance.singleMargin(bottom: 7),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
boxShadow: [
BoxShadow(
offset: Offset(0.w, 3.h),
blurRadius: 6,
color: AppColors.shadowHomeIcon,
),
],
borderRadius: const BorderRadius.all(Radius.circular(8)),
),
child: HighlightRichText(
text: title.trim(),
highlight: searchHighLight,
style: AppTheme.instance.fontCreator(
fontSizeFactor * 15,
FontWeights.medium,
theme!.isDarkMode ? AppColors.darkModeGreyText : AppColors.ahkamBlue3,
),
textAlign: Directionality.of(context) == TextDirection.rtl ? TextAlign.right : TextAlign.left,
),
),
);
}
}
class QTextShower extends StatelessWidget {
final String title;
final bool hasFullWidth;
final ReaderTheme? theme;
final double fontSizeFactor;
final String? searchHighLight;
const QTextShower({
super.key,
required this.title,
this.hasFullWidth = true,
this.theme = ReaderTheme.light,
this.fontSizeFactor = 1,
this.searchHighLight,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: hasFullWidth ? 1.sw : null,
child: Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: HighlightRichText(
text: title.trim(),
highlight: searchHighLight,
style: AppTheme.instance.fontCreator(
fontSizeFactor * 15,
FontWeights.medium,
theme!.color.red,
),
textAlign: Directionality.of(context) == TextDirection.rtl ? TextAlign.right : TextAlign.left,
),
),
);
}
}
class THeaderTextShower extends StatelessWidget {
final String title;
final bool hasFullWidth;
final ReaderTheme? theme;
final double fontSizeFactor;
final String? searchHighLight;
const THeaderTextShower({
super.key,
required this.title,
this.hasFullWidth = true,
this.theme = ReaderTheme.light,
this.fontSizeFactor = 1,
this.searchHighLight,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: hasFullWidth ? 1.sw : null,
child: Container(
decoration: const BoxDecoration(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: HighlightRichText(
text: title.trim(),
highlight: searchHighLight,
style: AppTheme.instance.fontCreator(
fontSizeFactor * 18,
FontWeights.bold,
theme!.isDarkMode ? AppColors.white : AppColors.ahkamBlue3,
),
textAlign: Directionality.of(context) == TextDirection.rtl ? TextAlign.right : TextAlign.left,
),
),
);
}
}
extension ReplaceTag on String {
String replaceTHeader() {
return replaceAllMapped(
RegExp(r'<([a-z]+)[^>]*t-header[^>]*>(?:(?:<([a-z]+)[^>]*>[^><]*<\/(\2)>)|\n|[^><])*?<\/(\1)>'), (match) {
String result = match.group(0) ?? '';
result = result.replaceFirst(match.group(4).toString(), 't_header');
result = result.replaceAll('${match.group(4)}>', 't_header>');
return result;
});
}
String replaceQHeader() {
return replaceAllMapped(
RegExp(r'<([a-z]+)[^>]*q-header[^>]*>(?:(?:<([a-z]+)[^>]*>[^><]*<\/(\2)>)|\n|[^><])*?<\/(\1)>'), (match) {
String result = match.group(0) ?? '';
result = result.replaceFirst(match.group(4).toString(), 'q_header');
result = result.replaceAll('${match.group(4)}>', 'q_header>');
return result;
});
}
String replaceQText() {
return replaceAllMapped(
RegExp(r'<([a-z]+)[^>]*q-text[^>]*>(?:(?:<([a-z]+)[^>]*>[^><]*<\/(\2)>)|\n|[^><])*?<\/(\1)>'), (match) {
String result = match.group(0) ?? '';
result = result.replaceFirst(match.group(4).toString(), 'q_text');
result = result.replaceAll('${match.group(4)}>', 'q_text>');
return result;
});
}
String replaceQAnswer() {
return replaceAllMapped(
RegExp(r'<([a-z]+)[^>]*q-answer[^>]*>(?:(?:<([a-z]+)[^>]*>[^><]*<\/(\2)>)|\n|[^><])*?<\/(\1)>'), (match) {
String result = match.group(0) ?? '';
result = result.replaceFirst(match.group(4).toString(), 'q_answer');
result = result.replaceAll('${match.group(4)}>', 'q_answer>');
return result;
});
}
String replaceTextStyle() {
return replaceAll(RegExp(r'font-size: ?\d{1,2}px;'), '')
.replaceAll(RegExp(r'font-weight: ?[^;]*;'), '')
.replaceAll(RegExp(r'font-family: ?"[^/"]*"'), '');
}
}

551
lib/core/html/style.dart

@ -0,0 +1,551 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:sonnat/core/html/html_parser.dart';
import 'package:sonnat/core/html/src/css_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/marker.dart';
import 'package:sonnat/core/html/src/style/size.dart';
class Style {
Color? backgroundColor;
Color? color;
Map<String, int?>? counterIncrement;
Map<String, int?>? counterReset;
TextDirection? direction;
Display? display;
String? fontFamily;
List<String>? fontFamilyFallback;
List<FontFeature>? fontFeatureSettings;
FontSize? fontSize;
FontStyle? fontStyle;
FontWeight? fontWeight;
Height? height;
double? letterSpacing;
ListStyleImage? listStyleImage;
ListStyleType? listStyleType;
ListStylePosition? listStylePosition;
EdgeInsets? padding;
Marker? marker;
Margins? margin;
TextAlign? textAlign;
TextDecoration? textDecoration;
/// CSS attribute "`text-decoration-color`"
///
/// Inherited: no,
/// Default: Current color
Color? textDecorationColor;
/// CSS attribute "`text-decoration-style`"
///
/// Inherited: no,
/// Default: TextDecorationStyle.solid,
TextDecorationStyle? textDecorationStyle;
/// Loosely based on CSS attribute "`text-decoration-thickness`"
///
/// Uses a percent modifier based on the font size.
///
/// Inherited: no,
/// Default: 1.0 (specified by font size)
// TODO(Sub6Resources): Possibly base this more closely on the CSS attribute.
double? textDecorationThickness;
/// CSS attribute "`text-shadow`"
///
/// Inherited: yes,
/// Default: none,
List<Shadow>? textShadow;
/// CSS attribute "`vertical-align`"
///
/// Inherited: no,
/// Default: VerticalAlign.BASELINE,
VerticalAlign? verticalAlign;
/// CSS attribute "`white-space`"
///
/// Inherited: yes,
/// Default: WhiteSpace.NORMAL,
WhiteSpace? whiteSpace;
/// CSS attribute "`width`"
///
/// Inherited: no,
/// Default: Width.auto()
Width? width;
/// CSS attribute "`word-spacing`"
///
/// Inherited: yes,
/// Default: normal (0)
double? wordSpacing;
LineHeight? lineHeight;
String? before;
String? after;
Border? border;
Alignment? alignment;
Widget? markerContent;
/// MaxLine
///
///
///
///
int? maxLines;
/// TextOverflow
///
///
///
///
TextOverflow? textOverflow;
TextTransform? textTransform;
Style({
this.backgroundColor = Colors.transparent,
this.color,
this.counterIncrement,
this.counterReset,
this.direction,
this.display,
this.fontFamily,
this.fontFamilyFallback,
this.fontFeatureSettings,
this.fontSize,
this.fontStyle,
this.fontWeight,
this.height,
this.lineHeight,
this.letterSpacing,
this.listStyleImage,
this.listStyleType,
this.listStylePosition,
this.padding,
this.marker,
this.margin,
this.textAlign,
this.textDecoration,
this.textDecorationColor,
this.textDecorationStyle,
this.textDecorationThickness,
this.textShadow,
this.verticalAlign,
this.whiteSpace,
this.width,
this.wordSpacing,
this.before,
this.after,
this.border,
this.alignment,
this.markerContent,
this.maxLines,
this.textOverflow,
this.textTransform = TextTransform.none,
}) {
if (alignment == null && (display == Display.block || display == Display.listItem)) {
alignment = Alignment.centerLeft;
}
}
static Map<String, Style> fromThemeData(ThemeData theme) => {
'h1': Style.fromTextStyle(theme.textTheme.displayLarge!),
'h2': Style.fromTextStyle(theme.textTheme.displayMedium!),
'h3': Style.fromTextStyle(theme.textTheme.displaySmall!),
'h4': Style.fromTextStyle(theme.textTheme.headlineMedium!),
'h5': Style.fromTextStyle(theme.textTheme.headlineSmall!),
'h6': Style.fromTextStyle(theme.textTheme.titleLarge!),
'body': Style.fromTextStyle(theme.textTheme.bodyMedium!),
};
static Map<String, Style> fromCss(String css, OnCssParseError? onCssParseError) {
final declarations = parseExternalCss(css, onCssParseError);
Map<String, Style> styleMap = {};
declarations.forEach((key, value) {
styleMap[key] = declarationsToStyle(value);
});
return styleMap;
}
TextStyle generateTextStyle() {
return TextStyle(
backgroundColor: backgroundColor,
color: color,
decoration: textDecoration,
decorationColor: textDecorationColor,
decorationStyle: textDecorationStyle,
decorationThickness: textDecorationThickness,
fontFamily: fontFamily,
fontFamilyFallback: fontFamilyFallback,
fontFeatures: fontFeatureSettings,
fontSize: fontSize?.value,
fontStyle: fontStyle,
fontWeight: fontWeight,
letterSpacing: letterSpacing,
shadows: textShadow,
wordSpacing: wordSpacing,
height: lineHeight?.size ?? 1.0,
);
}
@override
String toString() {
return 'Style';
}
Style merge(Style other) {
return copyWith(
backgroundColor: other.backgroundColor,
color: other.color,
counterIncrement: other.counterIncrement,
counterReset: other.counterReset,
direction: other.direction,
display: other.display,
fontFamily: other.fontFamily,
fontFamilyFallback: other.fontFamilyFallback,
fontFeatureSettings: other.fontFeatureSettings,
fontSize: other.fontSize,
fontStyle: other.fontStyle,
fontWeight: other.fontWeight,
height: other.height,
lineHeight: other.lineHeight,
letterSpacing: other.letterSpacing,
listStyleImage: other.listStyleImage,
listStyleType: other.listStyleType,
listStylePosition: other.listStylePosition,
padding: other.padding,
margin: other.margin,
marker: other.marker,
textAlign: other.textAlign,
textDecoration: other.textDecoration,
textDecorationColor: other.textDecorationColor,
textDecorationStyle: other.textDecorationStyle,
textDecorationThickness: other.textDecorationThickness,
textShadow: other.textShadow,
verticalAlign: other.verticalAlign,
whiteSpace: other.whiteSpace,
width: other.width,
wordSpacing: other.wordSpacing,
before: other.before,
after: other.after,
border: other.border,
alignment: other.alignment,
markerContent: other.markerContent,
maxLines: other.maxLines,
textOverflow: other.textOverflow,
textTransform: other.textTransform,
);
}
Style copyOnlyInherited(Style child) {
FontSize? finalFontSize = FontSize.inherit(fontSize, child.fontSize);
LineHeight? finalLineHeight = child.lineHeight != null
? child.lineHeight?.units == 'length'
? LineHeight(child.lineHeight!.size! / (finalFontSize == null ? 14 : finalFontSize.value) * 1.2)
: child.lineHeight
: lineHeight;
return child.copyWith(
backgroundColor: child.backgroundColor != Colors.transparent ? child.backgroundColor : backgroundColor,
color: child.color ?? color,
direction: child.direction ?? direction,
display: display == Display.none ? display : child.display,
fontFamily: child.fontFamily ?? fontFamily,
fontFamilyFallback: child.fontFamilyFallback ?? fontFamilyFallback,
fontFeatureSettings: child.fontFeatureSettings ?? fontFeatureSettings,
fontSize: finalFontSize,
fontStyle: child.fontStyle ?? fontStyle,
fontWeight: child.fontWeight ?? fontWeight,
lineHeight: finalLineHeight,
letterSpacing: child.letterSpacing ?? letterSpacing,
listStyleImage: child.listStyleImage ?? listStyleImage,
listStyleType: child.listStyleType ?? listStyleType,
listStylePosition: child.listStylePosition ?? listStylePosition,
textAlign: child.textAlign ?? textAlign,
textDecoration: TextDecoration.combine([
child.textDecoration ?? TextDecoration.none,
textDecoration ?? TextDecoration.none,
]),
textShadow: child.textShadow ?? textShadow,
whiteSpace: child.whiteSpace ?? whiteSpace,
wordSpacing: child.wordSpacing ?? wordSpacing,
maxLines: child.maxLines ?? maxLines,
textOverflow: child.textOverflow ?? textOverflow,
textTransform: child.textTransform ?? textTransform,
);
}
Style copyWith({
Color? backgroundColor,
Color? color,
Map<String, int?>? counterIncrement,
Map<String, int?>? counterReset,
TextDirection? direction,
Display? display,
String? fontFamily,
List<String>? fontFamilyFallback,
List<FontFeature>? fontFeatureSettings,
FontSize? fontSize,
FontStyle? fontStyle,
FontWeight? fontWeight,
Height? height,
LineHeight? lineHeight,
double? letterSpacing,
ListStyleImage? listStyleImage,
ListStyleType? listStyleType,
ListStylePosition? listStylePosition,
EdgeInsets? padding,
Margins? margin,
Marker? marker,
TextAlign? textAlign,
TextDecoration? textDecoration,
Color? textDecorationColor,
TextDecorationStyle? textDecorationStyle,
double? textDecorationThickness,
List<Shadow>? textShadow,
VerticalAlign? verticalAlign,
WhiteSpace? whiteSpace,
Width? width,
double? wordSpacing,
String? before,
String? after,
Border? border,
Alignment? alignment,
Widget? markerContent,
int? maxLines,
TextOverflow? textOverflow,
TextTransform? textTransform,
bool? beforeAfterNull,
}) {
return Style(
backgroundColor: backgroundColor ?? this.backgroundColor,
color: color ?? this.color,
counterIncrement: counterIncrement ?? this.counterIncrement,
counterReset: counterReset ?? this.counterReset,
direction: direction ?? this.direction,
display: display ?? this.display,
fontFamily: fontFamily ?? this.fontFamily,
fontFamilyFallback: fontFamilyFallback ?? this.fontFamilyFallback,
fontFeatureSettings: fontFeatureSettings ?? this.fontFeatureSettings,
fontSize: fontSize ?? this.fontSize,
fontStyle: fontStyle ?? this.fontStyle,
fontWeight: fontWeight ?? this.fontWeight,
height: height ?? this.height,
lineHeight: lineHeight ?? this.lineHeight,
letterSpacing: letterSpacing ?? this.letterSpacing,
listStyleImage: listStyleImage ?? this.listStyleImage,
listStyleType: listStyleType ?? this.listStyleType,
listStylePosition: listStylePosition ?? this.listStylePosition,
padding: padding ?? this.padding,
margin: margin ?? this.margin,
marker: marker ?? this.marker,
textAlign: textAlign ?? this.textAlign,
textDecoration: textDecoration ?? this.textDecoration,
textDecorationColor: textDecorationColor ?? this.textDecorationColor,
textDecorationStyle: textDecorationStyle ?? this.textDecorationStyle,
textDecorationThickness: textDecorationThickness ?? this.textDecorationThickness,
textShadow: textShadow ?? this.textShadow,
verticalAlign: verticalAlign ?? this.verticalAlign,
whiteSpace: whiteSpace ?? this.whiteSpace,
width: width ?? this.width,
wordSpacing: wordSpacing ?? this.wordSpacing,
before: beforeAfterNull == true ? null : before ?? this.before,
after: beforeAfterNull == true ? null : after ?? this.after,
border: border ?? this.border,
alignment: alignment ?? this.alignment,
markerContent: markerContent ?? this.markerContent,
maxLines: maxLines ?? this.maxLines,
textOverflow: textOverflow ?? this.textOverflow,
textTransform: textTransform ?? this.textTransform,
);
}
Style.fromTextStyle(TextStyle textStyle) {
backgroundColor = textStyle.backgroundColor;
color = textStyle.color;
textDecoration = textStyle.decoration;
textDecorationColor = textStyle.decorationColor;
textDecorationStyle = textStyle.decorationStyle;
textDecorationThickness = textStyle.decorationThickness;
fontFamily = textStyle.fontFamily;
fontFamilyFallback = textStyle.fontFamilyFallback;
fontFeatureSettings = textStyle.fontFeatures;
fontSize = textStyle.fontSize != null ? FontSize(textStyle.fontSize!) : null;
fontStyle = textStyle.fontStyle;
fontWeight = textStyle.fontWeight;
letterSpacing = textStyle.letterSpacing;
textShadow = textStyle.shadows;
wordSpacing = textStyle.wordSpacing;
lineHeight = LineHeight(textStyle.height ?? 1.2);
textTransform = TextTransform.none;
}
/// Sets any dimensions set to rem or em to the computed size
void setRelativeValues(double remValue, double emValue) {
if (width?.unit == Unit.rem) {
width = Width(width!.value * remValue);
} else if (width?.unit == Unit.em) {
width = Width(width!.value * emValue);
}
if (height?.unit == Unit.rem) {
height = Height(height!.value * remValue);
} else if (height?.unit == Unit.em) {
height = Height(height!.value * emValue);
}
if (fontSize?.unit == Unit.rem) {
fontSize = FontSize(fontSize!.value * remValue);
} else if (fontSize?.unit == Unit.em) {
fontSize = FontSize(fontSize!.value * emValue);
}
Margin? marginLeft;
Margin? marginTop;
Margin? marginRight;
Margin? marginBottom;
if (margin?.left?.unit == Unit.rem) {
marginLeft = Margin(margin!.left!.value * remValue);
} else if (margin?.left?.unit == Unit.em) {
marginLeft = Margin(margin!.left!.value * emValue);
}
if (margin?.top?.unit == Unit.rem) {
marginTop = Margin(margin!.top!.value * remValue);
} else if (margin?.top?.unit == Unit.em) {
marginTop = Margin(margin!.top!.value * emValue);
}
if (margin?.right?.unit == Unit.rem) {
marginRight = Margin(margin!.right!.value * remValue);
} else if (margin?.right?.unit == Unit.em) {
marginRight = Margin(margin!.right!.value * emValue);
}
if (margin?.bottom?.unit == Unit.rem) {
marginBottom = Margin(margin!.bottom!.value * remValue);
} else if (margin?.bottom?.unit == Unit.em) {
marginBottom = Margin(margin!.bottom!.value * emValue);
}
margin = margin?.copyWith(
left: marginLeft,
top: marginTop,
right: marginRight,
bottom: marginBottom,
);
}
}
enum Display {
block,
inline,
inlineBlock,
listItem,
none,
}
enum ListStyleType {
arabicIndic('arabic-indic'),
armenian('armenian'),
lowerArmenian('lower-armenian'),
upperArmenian('upper-armenian'),
bengali('bengali'),
cambodian('cambodian'),
khmer('khmer'),
circle('circle'),
cjkDecimal('cjk-decimal'),
cjkEarthlyBranch('cjk-earthly-branch'),
cjkHeavenlyStem('cjk-heavenly-stem'),
cjkIdeographic('cjk-ideographic'),
decimal('decimal'),
decimalLeadingZero('decimal-leading-zero'),
devanagari('devanagari'),
disc('disc'),
disclosureClosed('disclosure-closed'),
disclosureOpen('disclosure-open'),
ethiopicNumeric('ethiopic-numeric'),
georgian('georgian'),
gujarati('gujarati'),
gurmukhi('gurmukhi'),
hebrew('hebrew'),
hiragana('hiragana'),
hiraganaIroha('hiragana-iroha'),
japaneseFormal('japanese-formal'),
japaneseInformal('japanese-informal'),
kannada('kannada'),
katakana('katakana'),
katakanaIroha('katakana-iroha'),
koreanHangulFormal('korean-hangul-formal'),
koreanHanjaInformal('korean-hanja-informal'),
koreanHanjaFormal('korean-hanja-formal'),
lao('lao'),
lowerAlpha('lower-alpha'),
lowerGreek('lower-greek'),
lowerLatin('lower-latin'),
lowerRoman('lower-roman'),
malayalam('malayalam'),
mongolian('mongolian'),
myanmar('myanmar'),
none('none'),
oriya('oriya'),
persian('persian'),
simpChineseFormal('simp-chinese-formal'),
simpChineseInformal('simp-chinese-informal'),
square('square'),
tamil('tamil'),
telugu('telugu'),
thai('thai'),
tibetan('tibetan'),
tradChineseFormal('trad-chinese-formal'),
tradChineseInformal('trad-chinese-informal'),
upperAlpha('upper-alpha'),
upperLatin('upper-latin'),
upperRoman('upper-roman');
final String counterStyle;
const ListStyleType(this.counterStyle);
factory ListStyleType.fromName(String name) {
return ListStyleType.values.firstWhere((value) {
return name == value.counterStyle;
});
}
}
class ListStyleImage {
final String uriText;
const ListStyleImage(this.uriText);
}
enum ListStylePosition {
outside,
inside,
}
enum TextTransform {
uppercase,
lowercase,
capitalize,
none,
}
enum VerticalAlign {
baseline,
sub,
sup,
}
enum WhiteSpace {
normal,
pre,
}

11
lib/core/language/language_cubit.dart

@ -2,6 +2,7 @@ import 'package:data/app_setting_data/repository/app_setting_box_repository_impl
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:local_db_core/lib/boxes/box_list/setting_box/app_setting_box.dart';
import 'package:repositories/app_setting_box_domain/repository/app_setting_box_repository.dart';
import 'package:sonnat/core/language/languages.dart';
import 'package:sonnat/core/language/translator.dart';
import 'package:sonnat/core/utils/base_cubit_type.dart';
@ -16,16 +17,6 @@ enum CurrentLanguage {
ar,
}
enum Languages {
fa('fa'),
en('en'),
ar('ar');
const Languages(this.value);
final String value;
}
class LanguageCubit extends Cubit<BaseCubitType<LanguageState>> {
final AppSettingBoxRepository _repository = AppSettingBoxRepositoryImpl(appSettingBox: AppSettingBox());

9
lib/core/language/languages.dart

@ -0,0 +1,9 @@
enum Languages {
fa('fa'),
en('en'),
ar('ar');
const Languages(this.value);
final String value;
}

1
lib/core/language/translator.dart

@ -6,6 +6,7 @@ import 'package:flutter/services.dart' show rootBundle;
import 'package:local_db_core/lib/boxes/box_list/setting_box/app_setting_box.dart';
import 'package:repositories/app_setting_box_domain/repository/app_setting_box_repository.dart';
import 'package:sonnat/core/language/language_cubit.dart';
import 'package:sonnat/core/language/languages.dart';
final List<String> supportedLanguages = [
'en',

74
lib/core/player_widgets/audio_player.dart

@ -0,0 +1,74 @@
import 'package:chewie_audio/chewie_audio.dart';
import 'package:flutter/material.dart';
import 'package:sonnat/core/theme/app_colors.dart';
import 'package:sonnat/core/widgets/global_loading.dart';
import 'package:video_player/video_player.dart';
class AudioPlayer extends StatefulWidget {
final String url;
const AudioPlayer({super.key, required this.url});
@override
State<AudioPlayer> createState() => _AudioPlayerState();
}
class _AudioPlayerState extends State<AudioPlayer> {
ChewieAudioController? chewieController;
late VideoPlayerController videoPlayerController;
@override
void initState() {
super.initState();
videoPlayerController = VideoPlayerController.network(widget.url);
}
Future<bool> initAudio() async {
await videoPlayerController.initialize();
chewieController = ChewieAudioController(
videoPlayerController: videoPlayerController,
autoPlay: false,
looping: false,
);
return Future.value(true);
}
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: initAudio(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Container(
decoration: BoxDecoration(
boxShadow: const [
BoxShadow(
color: AppColors.gray2,
blurRadius: 1,
offset: Offset.zero,
spreadRadius: 1,
),
],
borderRadius: BorderRadius.circular(16),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: ChewieAudio(
controller: chewieController!,
),
),
);
} else if (snapshot.hasError) {
return const Icon(Icons.error_outline);
}
return const GlobalLoading(isSmallSize: true);
});
}
@override
void dispose() {
videoPlayerController.dispose();
chewieController?.dispose();
super.dispose();
}
}

67
lib/core/player_widgets/video_player.dart

@ -0,0 +1,67 @@
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:sonnat/core/widgets/global_loading.dart';
import 'package:video_player/video_player.dart';
class VideoPlayer extends StatefulWidget {
final String url;
const VideoPlayer({super.key, required this.url});
@override
State<VideoPlayer> createState() => _VideoPlayerState();
}
class _VideoPlayerState extends State<VideoPlayer> {
late ChewieController? chewieController;
late VideoPlayerController videoPlayerController;
@override
void initState() {
super.initState();
videoPlayerController = VideoPlayerController.network(widget.url);
}
Future<bool> initVideo() async {
await videoPlayerController.initialize();
chewieController = ChewieController(
videoPlayerController: videoPlayerController,
autoPlay: false,
looping: false,
autoInitialize: true,
allowFullScreen: true,
);
return Future.value(true);
}
@override
Widget build(BuildContext context) {
if (videoPlayerController.value.isInitialized) {
return AspectRatio(
aspectRatio: chewieController!.aspectRatio ?? 16 / 9,
child: Chewie(controller: chewieController!),
);
}
return FutureBuilder(
future: initVideo(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return AspectRatio(
aspectRatio: chewieController!.aspectRatio ?? 16 / 9,
child: Chewie(controller: chewieController!),
);
}
if (snapshot.hasError) {
return const Icon(Icons.error_outline);
}
return const GlobalLoading(isSmallSize: true);
});
}
@override
void dispose() {
videoPlayerController.dispose();
chewieController?.dispose();
super.dispose();
}
}

144
lib/core/theme/app_colors.dart

@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
class AppColors {
///Colors
static const Color darkGreen = Color(0xFF325D79);
static const Color lightOrange = Color(0xffF9A26C);
static const Color orange = Color(0xffF26627);
static const Color lightGray = Color(0xFFEFEEEE);
static const Color veryLightGray = Color(0xFFF5F8FF);
static const Color shadow = Color(0x293E4F86);
static const Color shadowHomeIcon = Color(0x293E4F86);
static const Color textSemiBlack = Color(0xff282A25);
static const Color gray = Color(0xFFC9CFD5);
static const Color gray2 = Color(0xFFC9CCD5);
static const Color gray5 = Color(0xFFD4D3CF);
static const Color white = Color(0xFFFFFFFF);
static const Color white2 = Color(0xFFFEFEFE);
static const Color blue = Color(0xFF017ABF);
static const Color gradient = Color(0xFF4E4722);
static const Color lightGradient = Color(0xFF4E4410);
static const Color googleRed = Color(0xFFdd4b39);
/// -------- home Icons Colors ----------
static const Color lightBlue = Color(0xFF20CEC7);
static const Color lightGreen = Color(0xFF02D175);
static const Color lightGreen3 = Color(0xFF32B767);
static const Color lightPurple = Color(0xFFFF8E7D);
static const Color lightRed = Color(0xFFFF60AC);
static const Color lightRed4 = Color(0xFFFD76B7);
static const Color lightRed5 = Color(0xFFED64F2);
static const Color lightRed2 = Color(0xFFFF7979);
static const Color lightRed3 = Color(0xFFF73737);
static const Color lightBlue2 = Color(0xFF298BE9);
static const Color lightBlue3 = Color(0xFF2CE8E8);
static const Color lightBlue4 = Color(0xFF3CB1FD);
static const Color lightGreen2 = Color(0xFF12D49F);
static const Color newBlue = Color(0xFF4D82ff);
static const Color textBlack = Color(0xFF464646);
static const Color textBlackLight = Color(0xFF575757);
/// -------- calendar Colors ----------
static const List<Color> purple2orange = [calendarSecond, calendarFirst];
static const Color calendarSecond = Color(0xFFFF60AC);
static const Color calendarFirst = Color(0xFFFF8E7D);
static const Color calendarMixedOfTwo = Color(0xFFFD7893);
/// -------- quran Colors ----------
static const List<Color> green2lightGreen = [Color(0xF020CEC7), Color(0xF002D175)];
static const Color backWhite = Color(0xFFF5F7FA);
static const Color borderLine = Color(0xFFD7DBE2);
static const Color gray3 = Color(0xFFF5F7FA);
static const Color gray4 = Color(0xFFF1F2F3);
static const Color gray6 = Color(0xFF2F323A);
static const Color gold = Color(0xFFD4CE84);
static const Color pageBlue = Color(0xFF8990A1);
static const Color lightGreenEnd = Color(0xFF20CEC7);
static const Color quranPaperColor = Color(0xFFE9F6F1);
static const Color quranLightPaperColor = Color(0xFFF1F6F5);
///lightPhosphoric
static const Color lightPhosphoric = Color(0xFF1CC4B9);
static const Color besmray2 = Color(0xFFA0A3AC);
/// -------- mafatih olors ----------
static const List<Color> yellow2orange = [
Color(0xFFfC7777),
Color(0xFFFDD576),
];
static const Color mafatihRed = Color(0xFffC7777);
static const Color mafatihRed2 = Color(0xFfFDD576);
static const Color cardBlue = Color(0xFFBCC1CD);
static const Color paperColor = Color(0xFFEFE9E2);
static const Color lightPaperColor = Color(0xFFF8F3EE);
/// -------- ahkam Colors ----------
static const List<Color> blue2lightBlue = [Color(0xFF298BE9), Color(0xFF2CE8E8)];
static const Color ahkamBlue = Color(0xFF2CE8E8);
static const Color ahkamBlue2 = Color(0xFF298BE9);
static const Color ahkamBlue3 = Color(0xFF0C67C2);
/// -------- Sky Colors ----------
static const Color sky = Color(0xFF3C57A8);
static const Color sky2 = Color(0xFf0380C3);
/// -------- husseinieh Colors ----------
static const List<Color> red2red = [
Color(0xF0780000),
Color(0xF0ff4a4a),
];
static const Color husseiniehRed2 = Color(0xFFFF0000);
static const Color husseiniehRed = Color(0xFFFF6F6F);
static const Color husseiniehRed3 = Color(0xFFA71919);
static const Color husseiniehRed4 = Color(0xFFD12727);
static const Color husseiniehListItem = Color(0xFF282828);
static const Color husseiniehTabBar = Color(0xFF232731);
static const Color husseiniehBackground = Color(0xFF2E2E2E);
static const Color husseiniehCard = Color(0xFF3B4050);
static const Color husseiniehTextField = Color(0xFF4E5467);
static const Color husseiniehBottomSheet = Color(0xFF272B37);
///***********setting colors -----------
static const Color settingBlue = Color(0xFF2049EB);
static const Color settingSemiBlack = Color(0xFF222D4E);
static const Color settingDropDownBackGround = Color(0xFFECEFF4);
static const Color settingTextBlue = Color(0xFF848BA0);
static const Color settingGrey = Color(0xFFABAFBE);
///----------- hadith colors --------------
static const List<Color> green2DarkGreen = [Color(0xff05b96a), Color(0xff05d96e)];
static const Color hadithSemiBlack = Color(0xFF222D4E);
static const Color hadithGreenSemiBlack = Color(0xFF224E26);
static const Color hadithBlack = Color(0xFF34363B);
static const Color hadithGreen = Color(0xea05b96a);
static const Color hadithGreen2 = Color(0xea05d96e);
static const Color hadithDarkGreen = Color(0xFF10AF5A);
static const Color hadithTagsGrey = Color(0xFFF9F9F9);
static const Color hadithTagsBack = Color(0xFFE1E4E9);
static const Color hadithTagsTxt = Color(0xFF9397A6);
static const Color hadithGrey = Color(0xFFA0A7B6);
static const Color hadithTagGrey = Color(0xFFD5DBE5);
static const Color hadithBorder = Color(0xFFBEC8D3);
static const Color hadithDarkGrey = Color(0xFF425762);
static const Color hadithDarkText = Color(0xFF6F778D);
static const Color hadithShadow = Color(0xFF848BA0);
static const Color shimmerBase = Color(0xffd9d9d9);
static const Color shimmerHighlight = Color(0xffe3e3e3);
static const Color selectedText = Color(0xffFAFF68);
static Color hadithGreenLowOpacity = hadithGreen.withOpacity(0.8);
static Color hadithGreen2LowOpacity = hadithGreen2.withOpacity(0.8);
/// dark mode colors
static const Color darkModeGreyText = Color(0xFFC8C8C8);
static const Color darkModeGrey = Color(0xFF7F8084);
static const Color darkModeItemBack = Color(0xFF333333);
static const Color darkModeItemBack2 = Color(0xFF4F4F4F);
static const Color darkModeBack = Color(0xFF161616);
}

130
lib/core/theme/app_theme.dart

@ -0,0 +1,130 @@
import 'package:data/app_setting_data/repository/app_setting_box_repository_impl.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:local_db_core/lib/boxes/box_list/setting_box/app_setting_box.dart';
import 'package:repositories/app_setting_box_domain/repository/app_setting_box_repository.dart';
enum FontWeights {
light,
regular,
medium,
bold,
black,
}
enum FontFamilyName {
segoeui,
moshaf,
moshafAEO,
sname,
aar,
aar2,
times,
noor,
noorAeo,
}
extension FontFamilyNameExtension on FontFamilyName {
String get name {
switch (this) {
case FontFamilyName.segoeui:
return 'Vazir';
case FontFamilyName.moshaf:
return 'Moshaf';
case FontFamilyName.moshafAEO:
return 'MoshafAEO';
case FontFamilyName.sname:
return 'sname';
case FontFamilyName.aar:
return 'aar';
case FontFamilyName.aar2:
return 'aar2';
case FontFamilyName.times:
return 'times';
case FontFamilyName.noor:
return 'noor';
case FontFamilyName.noorAeo:
return 'noor_aeo';
}
}
}
class AppTheme {
AppTheme.privateConstructor();
static final AppTheme instance = AppTheme.privateConstructor();
factory AppTheme() {
return instance;
}
final AppSettingBoxRepository _localRepository = AppSettingBoxRepositoryImpl(appSettingBox: AppSettingBox());
ThemeData lightThemeData = ThemeData(
brightness: Brightness.light,
primaryColor: Colors.teal,
checkboxTheme: const CheckboxThemeData().copyWith(),
colorScheme: const ColorScheme.light().copyWith(
primary: const Color(0xFF2049EB),
secondary: Colors.white.withOpacity(0),
),
textTheme: const TextTheme(
displayLarge: TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold),
titleLarge: TextStyle(fontSize: 16.0, fontStyle: FontStyle.italic),
bodyMedium: TextStyle(fontSize: 12.0),
),
);
final segoeFontName = 'Vazir';
TextStyle fontCreator(
double fontSize,
FontWeights fontWeights,
fontColor, [
FontFamilyName? fontName,
wordSpacing,
lineHeight,
shadow,
]) {
String languageCode = _localRepository.getCurrentLanguage() ?? 'fa';
if (languageCode == 'ar' &&
(fontName == null || fontName == FontFamilyName.segoeui || fontName == FontFamilyName.times)) {
return GoogleFonts.notoSansArabic(
color: fontColor,
fontStyle: FontStyle.normal,
fontWeight: fontWeights == FontWeights.light
? FontWeight.w300
: fontWeights == FontWeights.regular
? FontWeight.w400
: fontWeights == FontWeights.medium
? FontWeight.w500
: fontWeights == FontWeights.bold
? FontWeight.w700
: FontWeight.w800,
height: (lineHeight ?? 1) * 1.4,
wordSpacing: wordSpacing,
fontSize: fontSize,
shadows: shadow,
);
}
return TextStyle(
color: fontColor,
fontStyle: FontStyle.normal,
fontFamily: fontName?.name ?? segoeFontName,
fontWeight: fontWeights == FontWeights.light
? FontWeight.w300
: fontWeights == FontWeights.regular
? FontWeight.w400
: fontWeights == FontWeights.medium
? FontWeight.w500
: fontWeights == FontWeights.bold
? FontWeight.w700
: FontWeight.w800,
height: lineHeight,
wordSpacing: wordSpacing,
fontSize: fontSize,
shadows: shadow,
);
}
}

4
lib/core/theme/cubit/theme_cubit.dart

@ -17,9 +17,9 @@ class ThemeCubit extends Cubit<BaseCubitType<ThemeState>> {
PanelTheme get currentTheme => _currentTheme;
PanelColors get colors => _currentTheme.colors;
sonnatColors get colors => _currentTheme.colors;
PanelTypography get typo => _currentTheme.typography;
sonnatTypography get typo => _currentTheme.typography;
}
enum ThemeState {

8
lib/core/theme/panel_colors.dart

@ -1,6 +1,6 @@
import 'dart:ui';
abstract class PanelColors {
abstract class sonnatColors {
final Color primary;
final Color primary80;
final Color onPrimary;
@ -75,7 +75,7 @@ abstract class PanelColors {
final Color additionalLightGreen;
final Color lightError;
PanelColors({
sonnatColors({
required this.onBackground2,
required this.additionalLightGreen,
required this.additionalGreen,
@ -147,7 +147,7 @@ abstract class PanelColors {
});
}
class LightThemeColors extends PanelColors {
class LightThemeColors extends sonnatColors {
LightThemeColors()
: super(
onBackground2: const Color(0xff1B1B1F),
@ -221,7 +221,7 @@ class LightThemeColors extends PanelColors {
);
}
class DarkThemeColors extends PanelColors {
class DarkThemeColors extends sonnatColors {
DarkThemeColors()
: super(
onBackground2: const Color(0xffE3E2E6),

4
lib/core/theme/panel_theme.dart

@ -4,8 +4,8 @@ import 'package:sonnat/core/theme/panel_typography.dart';
abstract class PanelTheme {
PanelTheme({required this.colors, required this.typography});
final PanelColors colors;
final PanelTypography typography;
final sonnatColors colors;
final sonnatTypography typography;
}
class LightTheme extends PanelTheme {

6
lib/core/theme/panel_typography.dart

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
abstract class PanelTypography {
PanelTypography({
abstract class sonnatTypography {
sonnatTypography({
required this.heroText1Light,
required this.heroText1Medium,
required this.heroText1DemiBold,
@ -69,7 +69,7 @@ abstract class PanelTypography {
final TextStyle sourceLinkSmall;
}
class PersianTypo extends PanelTypography {
class PersianTypo extends sonnatTypography {
PersianTypo()
: super(
heroText1Light: const TextStyle(

37
lib/core/theme/reader_theme.dart

@ -0,0 +1,37 @@
import 'dart:ui';
import 'package:sonnat/core/theme/app_colors.dart';
abstract class IReaderTheme {
Color getMainColor();
}
enum ReaderTheme { light, paperYellow, paperGreen, dark }
extension EXReaderTheme on ReaderTheme {
bool get isDarkMode {
switch (this) {
case ReaderTheme.light:
return false;
case ReaderTheme.dark:
return true;
case ReaderTheme.paperYellow:
return false;
case ReaderTheme.paperGreen:
return false;
}
}
Color get color {
switch (this) {
case ReaderTheme.light:
return AppColors.white;
case ReaderTheme.dark:
return AppColors.darkModeItemBack2;
case ReaderTheme.paperYellow:
return AppColors.paperColor;
case ReaderTheme.paperGreen:
return AppColors.quranPaperColor;
}
}
}

23
lib/core/utils/app_utils.dart

@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:shamsi_date/shamsi_date.dart';
import 'package:sonnat/core/extensions/number_extension.dart';
import 'package:sonnat/core/language/language_cubit.dart';
import 'package:sonnat/core/utils/toast.dart';
@ -82,4 +83,26 @@ class Utils {
);
}
}
EdgeInsets allMargin(num? num) {
return EdgeInsets.all(
(num ?? 0).w,
);
}
EdgeInsets singleMargin({num? top, num? right, num? bottom, num? left}) {
return EdgeInsets.only(
top: (top ?? 0).h,
right: (right ?? 0).w,
bottom: (bottom ?? 0).h,
left: (left ?? 0).w,
);
}
EdgeInsets symmetricMargin({horizontal, vertical}) {
return EdgeInsets.symmetric(
horizontal: (horizontal ?? 0).w,
vertical: (vertical ?? 0).w,
);
}
}

86
lib/core/utils/url_launcher.dart

@ -0,0 +1,86 @@
import 'package:android_intent_plus/android_intent.dart';
import 'package:platform/platform.dart';
import 'package:sonnat/core/utils/utilities.dart';
import 'package:url_launcher/url_launcher.dart';
class UrlLauncher {
Future<void> launchInBrowser(String url) async {
try {
await launchUrl(
Uri.parse(url),
mode: LaunchMode.externalApplication,
);
} on Exception catch (_) {
if (Utilities.isAndroid) {
AndroidIntent intent = AndroidIntent(action: 'action_view', package: 'com.habibapp.habib', data: url);
await intent.launch();
}
}
}
Future<void> launchInWebViewOrVC(String url) async {
if (await canLaunchUrl(Uri.parse(url))) {
await launch(
url,
forceSafariVC: true,
forceWebView: true,
headers: <String, String>{'my_header_key': 'my_header_value'},
);
} else {
throw 'Could not launch $url';
}
}
Future<void> launchInWebViewWithJavaScript(String url) async {
if (await canLaunchUrl(Uri.parse(url))) {
await launch(
url,
forceSafariVC: true,
forceWebView: true,
enableJavaScript: true,
);
} else {
throw 'Could not launch $url';
}
}
Future<void> launchInWebViewWithDomStorage(String url) async {
if (await canLaunchUrl(Uri.parse(url))) {
await launch(
url,
forceSafariVC: true,
forceWebView: true,
enableDomStorage: true,
);
} else {
throw 'Could not launch $url';
}
}
Future<void> launchUniversalLinkIos(String url) async {
if (await canLaunchUrl(Uri.parse(url))) {
final bool nativeAppLaunchSucceeded = await launch(
url,
forceSafariVC: false,
universalLinksOnly: true,
);
if (!nativeAppLaunchSucceeded) {
await launchUrl(Uri.parse(url));
}
}
}
Future<void> makePhoneCall(String url) async {
await launchUrl(Uri.parse('tel:$url'));
}
Future<void> textTo(String url, String body) async {
if (const LocalPlatform().isAndroid) {
var uri = 'sms:+$url?$body';
await launchUrl(Uri.parse(uri));
} else if (const LocalPlatform().isIOS) {
var uri = 'sms:00$url?$body';
await launchUrl(Uri.parse(uri));
}
}
}

45
lib/core/utils/utilities.dart

@ -0,0 +1,45 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
class Utilities {
static String charInjector(
String s,
String char,
int loopIndex, {
bool loop = false,
}) {
var text = s.split('').reversed.join();
if (!loop) {
if (text.length < loopIndex) {
return s;
}
var before = text.substring(0, loopIndex);
var after = text.substring(loopIndex, text.length);
return before + char + after;
} else {
if (loopIndex == 0) {
return s;
}
var a = StringBuffer();
for (var i = 0; i < text.length; i++) {
if (i != 0 && i % loopIndex == 0) {
a.write(char);
}
a.write(String.fromCharCode(text.runes.elementAt(i)));
}
return a.toString().split('').reversed.join();
}
}
static bool get isWeb => kIsWeb;
static bool get isAndroid => Platform.isAndroid;
static bool get isIos => Platform.isIOS;
static bool get isMobile => Platform.isAndroid || Platform.isIOS;
static bool get isDesktop => Platform.isWindows || Platform.isMacOS || Platform.isMacOS;
}

52
lib/core/widgets/custom_rich_text.dart

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
class HighlightRichText extends StatelessWidget {
final String text;
final String? highlight;
final TextStyle? style;
final TextStyle? highlightTextStyle;
final TextAlign? textAlign;
const HighlightRichText({
super.key,
required this.text,
this.highlight,
this.style,
this.highlightTextStyle,
this.textAlign,
});
@override
Widget build(BuildContext context) {
if (highlight == null) {
return Text(
text,
style: style,
textAlign: textAlign,
);
}
var split = text.split(RegExp(highlight!, caseSensitive: false));
List<String> withSplit = List.empty(growable: true);
for (int i = 0; i < split.length; i++) {
if (i == split.length - 1) {
withSplit.add(split[i]);
} else {
withSplit.add(split[i]);
withSplit.add(highlight!);
}
}
return RichText(
textAlign: textAlign ?? TextAlign.start,
text: TextSpan(
style: style,
children: withSplit.map((e) {
if (e.toLowerCase().compareTo(highlight!.toLowerCase()) == 0) {
return TextSpan(text: e, style: highlightTextStyle ?? style?.copyWith(backgroundColor: Colors.yellow));
} else {
return TextSpan(text: e);
}
}).toList(),
),
);
}
}

54
lib/core/widgets/global_loading.dart

@ -0,0 +1,54 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:loading_animations/loading_animations.dart';
import 'package:lottie/lottie.dart';
import 'package:sonnat/core/extensions/number_extension.dart';
import 'package:sonnat/core/theme/app_colors.dart';
class GlobalLoading extends StatelessWidget {
final bool isSmallSize;
const GlobalLoading({super.key, this.isSmallSize = false});
@override
Widget build(BuildContext context) {
return SizedBox(
height: isSmallSize ? 0.25.sw : 1.sh,
child: Column(
children: [
if (!kIsWeb && Platform.isIOS && !isSmallSize)
const Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
BackButton(
color: Colors.white,
),
],
),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!isSmallSize)
Lottie.asset(
'assets/images/loading.json',
height: 0.25.sw,
fit: BoxFit.scaleDown,
),
if (isSmallSize)
LoadingBumpingLine.circle(
size: 30.h,
backgroundColor: AppColors.gray,
),
],
),
),
),
],
),
);
}
}

63
lib/core/widgets/show_image_widget.dart

@ -0,0 +1,63 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
class ShowImageWidget extends StatelessWidget {
final String imageUrl;
final _transformationController = TransformationController();
late TapDownDetails _doubleTapDetails;
ShowImageWidget(this.imageUrl, {super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
leading: GestureDetector(
child: const Icon(Icons.close, color: Colors.white),
onTap: () {
Navigator.pop(context);
},
),
backgroundColor: Colors.black,
elevation: 0,
),
body: Center(
child: GestureDetector(
onScaleUpdate: (ScaleUpdateDetails details) {},
onDoubleTap: _handleDoubleTap,
onDoubleTapDown: _handleDoubleTapDown,
child: InteractiveViewer(
minScale: 0.5,
maxScale: 20,
panEnabled: false,
transformationController: _transformationController,
child: Container(
height: MediaQuery.of(context).size.height,
alignment: Alignment.center,
child: CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.fill,
),
),
),
),
),
);
}
void _handleDoubleTapDown(TapDownDetails details) {
_doubleTapDetails = details;
}
void _handleDoubleTap() {
if (_transformationController.value != Matrix4.identity()) {
_transformationController.value = Matrix4.identity();
} else {
final position = _doubleTapDetails.localPosition;
_transformationController.value = Matrix4.identity()
..translate(-position.dx * 2, -position.dy * 2)
..scale(4.0);
}
}
}

17
lib/features/main/main_screen.dart

@ -41,11 +41,15 @@ class _MainScreenState extends State<MainScreen> {
children: [
Container(
margin: EdgeInsets.only(
top: context.height * 27 / AppConstants.instance.appHeight,
top: context.height * 20 / AppConstants.instance.appHeight,
left: context.width * 86 / AppConstants.instance.appWidth,
right: context.width * 86 / AppConstants.instance.appWidth,
),
child: Image.asset('ic_main_header'.pngPath),
child: Image.asset(
'ic_main_header'.pngPath,
width: context.width * 200 / AppConstants.instance.appWidth,
height: context.height * 200 / AppConstants.instance.appHeight,
),
),
Text(
Translator.translate('main_header_text'),
@ -54,7 +58,6 @@ class _MainScreenState extends State<MainScreen> {
fontSize: 28,
),
),
SizedBox(height: context.height * 5 / AppConstants.instance.appHeight),
Text(
Translator.translate('second_header_text'),
style: const TextStyle(
@ -71,7 +74,7 @@ class _MainScreenState extends State<MainScreen> {
left: context.width * 35 / AppConstants.instance.appWidth,
right: context.width * 35 / AppConstants.instance.appWidth,
top: context.height * 40 / AppConstants.instance.appHeight,
bottom: context.height * 90 / AppConstants.instance.appHeight,
bottom: context.height * 60 / AppConstants.instance.appHeight,
),
padding: EdgeInsets.symmetric(
vertical: context.height * 13 / AppConstants.instance.appHeight,
@ -93,7 +96,7 @@ class _MainScreenState extends State<MainScreen> {
),
),
SvgPicture.asset('ic_line'.svgPath),
SizedBox(height: context.height * 70 / AppConstants.instance.appHeight),
SizedBox(height: context.height * 30 / AppConstants.instance.appHeight),
Padding(
padding: EdgeInsets.symmetric(horizontal: context.width * 35 / AppConstants.instance.appWidth),
child: Row(
@ -115,7 +118,7 @@ class _MainScreenState extends State<MainScreen> {
],
),
),
SizedBox(height: context.height * 15 / AppConstants.instance.appHeight),
SizedBox(height: context.height * 10 / AppConstants.instance.appHeight),
Padding(
padding: EdgeInsets.symmetric(horizontal: context.width * 35 / AppConstants.instance.appWidth),
child: Row(
@ -137,7 +140,7 @@ class _MainScreenState extends State<MainScreen> {
],
),
),
SizedBox(height: context.height * 40 / AppConstants.instance.appHeight),
SizedBox(height: context.height * 20 / AppConstants.instance.appHeight),
],
),
);

2
lib/features/main/widget/main_item_widget.dart

@ -14,7 +14,7 @@ class MainItemWidget extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
width: context.width * 93 / AppConstants.instance.appWidth,
height: context.height * 95 / AppConstants.instance.appHeight,
height: context.width * 95 / AppConstants.instance.appWidth,
decoration: BoxDecoration(
color: const Color(0xff3733A1),
borderRadius: BorderRadius.circular(22),

13
lib/features/posts/screen/posts_screen.dart

@ -32,7 +32,8 @@ class _PostsScreenState extends State<PostsScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
body: SafeArea(
child: Column(
children: [
SizedBox(height: context.height * 26 / AppConstants.instance.appHeight),
Padding(
@ -88,15 +89,23 @@ class _PostsScreenState extends State<PostsScreen> {
Expanded(
child: ListView.builder(
itemBuilder: (context, index) {
return const PostItemWidget();
return GestureDetector(
onTap: _clickOnPost,
child: const PostItemWidget(),
);
},
itemCount: 10,
),
)
],
),
),
);
}
void _clickOnPost() {
}
}
class FilterItem {

2
lib/features/posts/widgets/filter_item_widget.dart

@ -19,7 +19,7 @@ class FilterItemWidget extends StatelessWidget {
left: context.width * 9 / AppConstants.instance.appWidth,
right: context.width * 9 / AppConstants.instance.appWidth,
top: context.height * 5 / AppConstants.instance.appHeight,
bottom: context.height * 10 / AppConstants.instance.appHeight,
bottom: context.height * 5 / AppConstants.instance.appHeight,
),
margin: EdgeInsetsDirectional.only(end: context.width * 4 / AppConstants.instance.appWidth),
decoration: BoxDecoration(

153
lib/features/single_post/screen/single_post_screen.dart

@ -0,0 +1,153 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:shimmer/shimmer.dart';
import 'package:sonnat/core/extensions/context_extension.dart';
import 'package:sonnat/core/html/html_viewer.dart';
import 'package:sonnat/core/language/translator.dart';
import 'package:sonnat/core/utils/app_constants.dart';
import 'package:sonnat/features/single_post/view_models/post.dart';
class SinglePostScreen extends StatefulWidget {
final Post post;
const SinglePostScreen({super.key, required this.post});
@override
State<SinglePostScreen> createState() => _SinglePostScreenState();
}
class _SinglePostScreenState extends State<SinglePostScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
Stack(
fit: StackFit.expand,
children: [
CachedNetworkImage(
imageUrl: widget.post.thumbnail?.lg ?? '',
fit: BoxFit.cover,
errorWidget: (context, url, error) {
return const Icon(
Icons.wifi_off,
size: 80,
color: Colors.black54,
);
},
placeholder: (context, url) => Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: Container(color: Colors.white),
),
),
const Positioned(
top: 0,
left: 8,
right: 8,
child: Row(
children: <Widget>[
BackButton(color: Colors.white),
],
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
height: 150,
width: 1,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withOpacity(0.7),
Colors.black.withOpacity(0),
],
),
),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Align(
alignment: Alignment.bottomCenter,
child: Row(
children: <Widget>[
Padding(
padding: const EdgeInsets.all(8),
child: ClipOval(
child: CachedNetworkImage(
imageUrl: /*post.thumbnail?.sm ??*/ '',
height: 30,
width: 30,
errorWidget: (context, url, error) {
return Image.asset(
'assets/images/png/avatar.jpg',
cacheWidth: 30 ~/ 1,
cacheHeight: 30 ~/ 1,
);
},
placeholder: (context, url) => Shimmer.fromColors(
baseColor: Colors.grey.shade300,
highlightColor: Colors.grey.shade100,
child: Container(color: Colors.white),
),
),
),
),
Text(
"${Translator.translate('author')} : ${widget.post.author ?? ""}",
style: const TextStyle(fontSize: 10),
),
],
),
),
),
const Padding(
padding: EdgeInsets.all(8),
child: Align(
alignment: Alignment.bottomCenter,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [],
),
),
),
],
),
Padding(
padding: EdgeInsetsDirectional.only(
start: context.width * 26 / AppConstants.instance.appWidth,
end: context.width * 37 / AppConstants.instance.appWidth,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'عدم بیعت صحابه با ابوبکر+سند',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const Text(
'1404/12/25',
style: TextStyle(fontSize: 11),
),
HTMLViewer(
htmlContent: widget.post.content ?? '',
fontSizeFactor: 1,
),
],
),
),
],
),
),
);
}
}

120
lib/features/single_post/view_models/post.dart

@ -0,0 +1,120 @@
import 'package:sonnat/features/single_post/view_models/thumbnail.dart';
class Post {
String? _url;
Thumbnails? _thumbnail;
List<Categories>? _categories;
String? _author;
String? _title;
String? _content;
String? _summary;
bool? _asSpecial;
bool? _status;
String? _createdAt;
String? get url => _url;
Thumbnails? get thumbnail => _thumbnail;
List<Categories>? get categories => _categories;
String? get author => _author;
String? get title => _title;
String? get content => _content;
String? get summary => _summary;
bool? get asSpecial => _asSpecial;
bool? get status => _status;
String? get createdAt => _createdAt;
Post(
{String? url,
Thumbnails? thumbnail,
List<Categories>? categories,
String? author,
String? title,
String? content,
String? summary,
bool? asSpecial,
bool? status,
String? createdAt}) {
_url = url;
_thumbnail = thumbnail;
_categories = categories;
_author = author;
_title = title;
_content = content;
_summary = summary;
_asSpecial = asSpecial;
_status = status;
_createdAt = createdAt;
}
Post.fromJson(dynamic json) {
_url = json['url'];
_thumbnail = json['thumbnail'] != null ? Thumbnails.fromJson(json['thumbnail']) : null;
if (json['categories'] != null) {
_categories = [];
json['categories'].forEach((v) {
_categories?.add(Categories.fromJson(v));
});
}
_author = json['author'];
_title = json['title'];
_content = json['content'];
_summary = json['summary'];
_asSpecial = json['as_special'];
_status = json['status'];
_createdAt = json['created_at'];
}
Map<String, dynamic> toJson() {
var map = <String, dynamic>{};
map['url'] = _url;
if (_thumbnail != null) {
map['thumbnail'] = _thumbnail?.toJson();
}
if (_categories != null) {
map['categories'] = _categories?.map((v) => v.toJson()).toList();
}
map['author'] = _author;
map['title'] = _title;
map['content'] = _content;
map['summary'] = _summary;
map['as_special'] = _asSpecial;
map['status'] = _status;
map['created_at'] = _createdAt;
return map;
}
}
class Categories {
String? _name;
String? _url;
String? get name => _name;
String? get url => _url;
Categories({String? name, String? url}) {
_name = name;
_url = url;
}
Categories.fromJson(dynamic json) {
_name = json['name'];
_url = json['url'];
}
Map<String, dynamic> toJson() {
var map = <String, dynamic>{};
map['name'] = _name;
map['url'] = _url;
return map;
}
}

31
lib/features/single_post/view_models/thumbnail.dart

@ -0,0 +1,31 @@
class Thumbnails {
String? _sm;
String? _md;
String? _lg;
String? get sm => _sm;
String? get md => _md;
String? get lg => _lg;
Thumbnails({String? sm, String? md, String? lg}) {
_sm = sm;
_md = md;
_lg = lg;
}
Thumbnails.fromJson(dynamic json) {
_sm = json['sm'];
_md = json['md'];
_lg = json['lg'];
}
Map<String, dynamic> toJson() {
var map = <String, dynamic>{};
map['sm'] = _sm;
map['md'] = _md;
map['lg'] = _lg;
return map;
}
}

278
pubspec.lock

@ -1,6 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
android_intent_plus:
dependency: "direct main"
description:
name: android_intent_plus
sha256: f79fbb8ccb64b5584d19caa9c3d15613bf21cfbd829a6ca7f089fb5dfd43f8aa
url: "https://pub.dev"
source: hosted
version: "4.0.0"
archive:
dependency: transitive
description:
@ -41,6 +49,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
cached_network_image:
dependency: "direct main"
description:
name: cached_network_image
sha256: fd3d0dc1d451f9a252b32d95d3f0c3c487bc41a75eba2e6097cb0b9c71491b15
url: "https://pub.dev"
source: hosted
version: "3.2.3"
cached_network_image_platform_interface:
dependency: transitive
description:
name: cached_network_image_platform_interface
sha256: bb2b8403b4ccdc60ef5f25c70dead1f3d32d24b9d6117cfc087f496b178594a7
url: "https://pub.dev"
source: hosted
version: "2.0.0"
cached_network_image_web:
dependency: transitive
description:
name: cached_network_image_web
sha256: b8eb814ebfcb4dea049680f8c1ffb2df399e4d03bf7a352c775e26fa06e02fa0
url: "https://pub.dev"
source: hosted
version: "1.0.2"
characters:
dependency: transitive
description:
@ -57,6 +89,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.3"
chewie:
dependency: "direct main"
description:
name: chewie
sha256: "745e81e84c6d7f3835f89f85bb49771c0a66099e4caf8f8e9e9a372bc66fb2c1"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
chewie_audio:
dependency: "direct main"
description:
name: chewie_audio
sha256: "73948a8b9841d050433af3498a1f8b11320bd5a2cd70b449bdbe16d4405e97c5"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
cli_util:
dependency: transitive
description:
@ -74,7 +122,7 @@ packages:
source: hosted
version: "1.1.1"
collection:
dependency: transitive
dependency: "direct main"
description:
name: collection
sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c"
@ -97,6 +145,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.3"
csslib:
dependency: "direct main"
description:
name: csslib
sha256: b36c7f7e24c0bdf1bf9a3da461c837d1de64b9f8beb190c9011d8c72a3dfd745
url: "https://pub.dev"
source: hosted
version: "0.17.2"
cupertino_icons:
dependency: "direct main"
description:
@ -149,6 +205,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.1.2"
flutter_blurhash:
dependency: transitive
description:
name: flutter_blurhash
sha256: "05001537bd3fac7644fa6558b09ec8c0a3f2eba78c0765f88912882b1331a5c6"
url: "https://pub.dev"
source: hosted
version: "0.7.0"
flutter_cache_manager:
dependency: transitive
description:
name: flutter_cache_manager
sha256: "32cd900555219333326a2d0653aaaf8671264c29befa65bbd9856d204a4c9fb3"
url: "https://pub.dev"
source: hosted
version: "3.3.0"
flutter_launcher_icons:
dependency: "direct dev"
description:
@ -183,6 +255,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: "6b6f10f0ce3c42f6552d1c70d2c28d764cf22bb487f50f66cca31dcd5194f4d6"
url: "https://pub.dev"
source: hosted
version: "4.0.4"
hive:
dependency: transitive
description:
@ -199,6 +279,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
html:
dependency: transitive
description:
name: html
sha256: "58e3491f7bf0b6a4ea5110c0c688877460d1a6366731155c4a4580e7ded773e8"
url: "https://pub.dev"
source: hosted
version: "0.15.3"
http:
dependency: transitive
description:
name: http
sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2"
url: "https://pub.dev"
source: hosted
version: "0.13.6"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b"
url: "https://pub.dev"
source: hosted
version: "4.0.2"
image:
dependency: transitive
description:
@ -231,6 +335,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
list_counter:
dependency: "direct main"
description:
name: list_counter
sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237
url: "https://pub.dev"
source: hosted
version: "1.0.2"
loading_animations:
dependency: "direct main"
description:
name: loading_animations
sha256: c62a8c1fbbe5ade3ac2814128a9aa92ee784756b11ad9e6a915b673b90005cc8
url: "https://pub.dev"
source: hosted
version: "2.2.0"
local_db_core:
dependency: "direct main"
description:
@ -238,6 +358,14 @@ packages:
relative: true
source: path
version: "1.0.0+1"
lottie:
dependency: "direct main"
description:
name: lottie
sha256: "23522951540d20a57a60202ed7022e6376bed206a4eee1c347a91f58bd57eb9f"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
matcher:
dependency: transitive
description:
@ -270,6 +398,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
octo_image:
dependency: transitive
description:
name: octo_image
sha256: "107f3ed1330006a3bea63615e81cf637433f5135a52466c7caa0e7152bca9143"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
path:
dependency: transitive
description:
@ -334,6 +470,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.6"
pedantic:
dependency: transitive
description:
name: pedantic
sha256: "67fc27ed9639506c856c840ccce7594d0bdcd91bc8d53d6e52359449a1d50602"
url: "https://pub.dev"
source: hosted
version: "1.11.1"
petitparser:
dependency: transitive
description:
@ -389,6 +533,14 @@ packages:
relative: true
source: path
version: "0.0.1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb"
url: "https://pub.dev"
source: hosted
version: "0.27.7"
shamsi_date:
dependency: "direct main"
description:
@ -397,6 +549,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.1"
shimmer:
dependency: "direct main"
description:
name: shimmer
sha256: "1f1009b5845a1f88f1c5630212279540486f97409e9fc3f63883e71070d107bf"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
sky_engine:
dependency: transitive
description: flutter
@ -410,6 +570,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
sqflite:
dependency: transitive
description:
name: sqflite
sha256: b4d6710e1200e96845747e37338ea8a819a12b51689a3bcf31eff0003b37a0b9
url: "https://pub.dev"
source: hosted
version: "2.2.8+4"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: e77abf6ff961d69dfef41daccbb66b51e9983cdd5cb35bf30733598057401555
url: "https://pub.dev"
source: hosted
version: "2.4.5"
stack_trace:
dependency: transitive
description:
@ -434,6 +610,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.0"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: "5fcbd27688af6082f5abd611af56ee575342c30e87541d0245f7ff99faa02c60"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
term_glyph:
dependency: transitive
description:
@ -522,6 +706,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.6"
uuid:
dependency: transitive
description:
name: uuid
sha256: "648e103079f7c64a36dc7d39369cabb358d377078a051d6ae2ad3aa539519313"
url: "https://pub.dev"
source: hosted
version: "3.0.7"
vector_graphics:
dependency: transitive
description:
@ -554,14 +746,94 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
video_player:
dependency: "direct main"
description:
name: video_player
sha256: de95f0e9405f29b5582573d4166132e71f83b3158aac14e8ee5767a54f4f1fbd
url: "https://pub.dev"
source: hosted
version: "2.6.1"
video_player_android:
dependency: transitive
description:
name: video_player_android
sha256: ae1c7d9a71c236a1bf9e567bd7ed4c90887e389a5f233b2192593f7f7395005c
url: "https://pub.dev"
source: hosted
version: "2.4.8"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: "4c274e439f349a0ee5cb3c42978393ede173a443b98f50de6ffe6900eaa19216"
url: "https://pub.dev"
source: hosted
version: "2.4.6"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: a8c4dcae2a7a6e7cc1d7f9808294d968eca1993af34a98e95b9bdfa959bec684
url: "https://pub.dev"
source: hosted
version: "6.1.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "44ce41424d104dfb7cf6982cc6b84af2b007a24d126406025bf40de5d481c74c"
url: "https://pub.dev"
source: hosted
version: "2.0.16"
wakelock:
dependency: transitive
description:
name: wakelock
sha256: "769ecf42eb2d07128407b50cb93d7c10bd2ee48f0276ef0119db1d25cc2f87db"
url: "https://pub.dev"
source: hosted
version: "0.6.2"
wakelock_macos:
dependency: transitive
description:
name: wakelock_macos
sha256: "047c6be2f88cb6b76d02553bca5a3a3b95323b15d30867eca53a19a0a319d4cd"
url: "https://pub.dev"
source: hosted
version: "0.4.0"
wakelock_platform_interface:
dependency: transitive
description:
name: wakelock_platform_interface
sha256: "1f4aeb81fb592b863da83d2d0f7b8196067451e4df91046c26b54a403f9de621"
url: "https://pub.dev"
source: hosted
version: "0.3.0"
wakelock_web:
dependency: transitive
description:
name: wakelock_web
sha256: "1b256b811ee3f0834888efddfe03da8d18d0819317f20f6193e2922b41a501b5"
url: "https://pub.dev"
source: hosted
version: "0.4.0"
wakelock_windows:
dependency: transitive
description:
name: wakelock_windows
sha256: "857f77b3fe6ae82dd045455baa626bc4b93cb9bb6c86bf3f27c182167c3a5567"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
win32:
dependency: transitive
description:
name: win32
sha256: "5a751eddf9db89b3e5f9d50c20ab8612296e4e8db69009788d6c8b060a84191c"
sha256: a6f0236dbda0f63aa9a25ad1ff9a9d8a4eaaa5012da0dc59d21afdb1dc361ca4
url: "https://pub.dev"
source: hosted
version: "4.1.4"
version: "3.1.4"
xdg_directories:
dependency: transitive
description:

13
pubspec.yaml

@ -11,15 +11,27 @@ dependencies:
flutter_svg: ^2.0.5
path_provider: ^2.0.11
cupertino_icons: ^1.0.2
chewie: ^1.5.0
lottie: ^2.3.2
loading_animations: ^2.1.0
flutter_bloc: ^8.1.1
android_intent_plus: ^4.0.0
csslib: ^0.17.2
collection: ^1.17.0
list_counter: ^1.0.2
shamsi_date: ^1.0.1
url_launcher: ^6.1.11
cached_network_image: ^3.1.0
google_fonts: ^4.0.4
chewie_audio: ^1.5.0
shimmer: ^2.0.0
local_db_core:
path: data/data_core/local_db/local_db_core
repositories:
path: domain/repositories
data:
path: data/data_types/data
video_player: ^2.6.1
dev_dependencies:
flutter_test:
@ -34,6 +46,7 @@ flutter:
- assets/images/png/
- assets/images/svg/
- assets/meta/
- assets/lottie/
- assets/fonts/arabi/
- assets/fonts/farsi/
fonts:

Loading…
Cancel
Save