mohsen zamani
2 years ago
53 changed files with 11558 additions and 114 deletions
-
11android/app/build.gradle
-
18android/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