mohsen zamani
2 years ago
53 changed files with 11558 additions and 114 deletions
-
11android/app/build.gradle
-
22android/app/src/main/AndroidManifest.xml
-
4android/build.gradle
-
4007assets/lottie/loading.json
-
18lib/core/extensions/number_extension.dart
-
489lib/core/html/custom_render.dart
-
326lib/core/html/flutter_html.dart
-
877lib/core/html/html_parser.dart
-
251lib/core/html/html_viewer.dart
-
45lib/core/html/src/anchor.dart
-
734lib/core/html/src/css_box_widget.dart
-
1172lib/core/html/src/css_parser.dart
-
207lib/core/html/src/html_elements.dart
-
64lib/core/html/src/interactable_element.dart
-
209lib/core/html/src/layout_element.dart
-
167lib/core/html/src/replaced_element.dart
-
30lib/core/html/src/style/fontsize.dart
-
64lib/core/html/src/style/length.dart
-
24lib/core/html/src/style/lineheight.dart
-
73lib/core/html/src/style/margin.dart
-
35lib/core/html/src/style/marker.dart
-
15lib/core/html/src/style/size.dart
-
411lib/core/html/src/styled_element.dart
-
89lib/core/html/src/utils.dart
-
220lib/core/html/string_proccess.dart
-
551lib/core/html/style.dart
-
11lib/core/language/language_cubit.dart
-
9lib/core/language/languages.dart
-
1lib/core/language/translator.dart
-
74lib/core/player_widgets/audio_player.dart
-
67lib/core/player_widgets/video_player.dart
-
144lib/core/theme/app_colors.dart
-
130lib/core/theme/app_theme.dart
-
4lib/core/theme/cubit/theme_cubit.dart
-
8lib/core/theme/panel_colors.dart
-
4lib/core/theme/panel_theme.dart
-
6lib/core/theme/panel_typography.dart
-
37lib/core/theme/reader_theme.dart
-
23lib/core/utils/app_utils.dart
-
86lib/core/utils/url_launcher.dart
-
45lib/core/utils/utilities.dart
-
52lib/core/widgets/custom_rich_text.dart
-
54lib/core/widgets/global_loading.dart
-
63lib/core/widgets/show_image_widget.dart
-
17lib/features/main/main_screen.dart
-
2lib/features/main/widget/main_item_widget.dart
-
13lib/features/posts/screen/posts_screen.dart
-
2lib/features/posts/widgets/filter_item_widget.dart
-
153lib/features/single_post/screen/single_post_screen.dart
-
120lib/features/single_post/view_models/post.dart
-
31lib/features/single_post/view_models/thumbnail.dart
-
278pubspec.lock
-
13pubspec.yaml
4007
assets/lottie/loading.json
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -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(); |
|||
} |
@ -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)); |
|||
} |
@ -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, |
|||
), |
|||
); |
|||
} |
|||
} |
@ -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, |
|||
}); |
|||
} |
@ -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, |
|||
), |
|||
); |
|||
} |
|||
} |
@ -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}'; |
|||
} |
|||
} |
@ -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
File diff suppressed because it is too large
View File
@ -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', |
|||
]; |
|||
} |
@ -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]]', |
|||
); |
|||
} |
|||
} |
@ -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]]'); |
|||
} |
|||
} |
@ -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!); |
|||
} |
|||
} |
@ -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; |
|||
} |
@ -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); |
|||
} |
@ -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); |
|||
} |
@ -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); |
|||
} |
@ -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; |
|||
} |
@ -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); |
|||
} |
@ -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); |
|||
})); |
|||
} |
|||
} |
@ -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; |
|||
} |
|||
} |
|||
} |
@ -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: ?"[^/"]*"'), ''); |
|||
} |
|||
} |
@ -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, |
|||
} |
@ -0,0 +1,9 @@ |
|||
enum Languages { |
|||
fa('fa'), |
|||
en('en'), |
|||
ar('ar'); |
|||
|
|||
const Languages(this.value); |
|||
|
|||
final String value; |
|||
} |
@ -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(); |
|||
} |
|||
} |
@ -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(); |
|||
} |
|||
} |
@ -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); |
|||
} |
@ -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, |
|||
); |
|||
} |
|||
} |
@ -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; |
|||
} |
|||
} |
|||
} |
@ -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)); |
|||
} |
|||
} |
|||
} |
@ -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; |
|||
} |
@ -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(), |
|||
), |
|||
); |
|||
} |
|||
} |
@ -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, |
|||
), |
|||
], |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
} |
|||
} |
@ -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); |
|||
} |
|||
} |
|||
} |
@ -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, |
|||
), |
|||
], |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
} |
@ -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; |
|||
} |
|||
} |
@ -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; |
|||
} |
|||
} |
Write
Preview
Loading…
Cancel
Save
Reference in new issue