From e8fd11c85bc9782f03751488b23564b22217e29d Mon Sep 17 00:00:00 2001 From: Roland Fredenhagen Date: Thu, 25 Mar 2021 14:15:14 +0100 Subject: [PATCH] feat(image): :sparkles: Added Details view for Images Users can zoom, quarter-rotate and freely rotate Images Updated flutterw Refs GitJournal#410 GitJournal#386 --- .flutter | 2 +- assets/langs/en.yaml | 6 + lib/screens/settings_display_images.dart | 54 ++- lib/settings.dart | 36 ++ lib/widgets/image_renderer.dart | 480 ----------------------- lib/widgets/images/image_caption.dart | 178 +++++++++ lib/widgets/images/image_details.dart | 106 +++++ lib/widgets/images/markdown_image.dart | 227 +++++++++++ lib/widgets/images/themable_image.dart | 248 ++++++++++++ lib/widgets/markdown_renderer.dart | 4 +- pubspec.lock | 7 + pubspec.yaml | 1 + 12 files changed, 863 insertions(+), 486 deletions(-) delete mode 100644 lib/widgets/image_renderer.dart create mode 100644 lib/widgets/images/image_caption.dart create mode 100644 lib/widgets/images/image_details.dart create mode 100644 lib/widgets/images/markdown_image.dart create mode 100644 lib/widgets/images/themable_image.dart diff --git a/.flutter b/.flutter index 9b2d32b6..4d7946a6 160000 --- a/.flutter +++ b/.flutter @@ -1 +1 @@ -Subproject commit 9b2d32b605630f28625709ebd9d78ab3016b2bf6 +Subproject commit 4d7946a68d26794349189cf21b3f68cc6fe61dcb diff --git a/assets/langs/en.yaml b/assets/langs/en.yaml index 9e6ce4c3..371abf58 100644 --- a/assets/langs/en.yaml +++ b/assets/langs/en.yaml @@ -49,6 +49,12 @@ settings: validator: empty: Tags cannot be empty. same: Tag cannot be identical to a “DoNotCaption-Tag”. + detailsView: + header: Detail View + maxZoom: Maximal zoom level + rotateGestures: + title: Rotate Image with gestures + subtitle: Rotate by moving two fingers in a circle theming: title: Image Theming subtitle: Configure how images are themed diff --git a/lib/screens/settings_display_images.dart b/lib/screens/settings_display_images.dart index f0d0f022..0935b6ee 100644 --- a/lib/screens/settings_display_images.dart +++ b/lib/screens/settings_display_images.dart @@ -14,12 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:gitjournal/screens/settings_display_images_caption.dart'; import 'package:gitjournal/screens/settings_display_images_theming.dart'; +import 'package:gitjournal/screens/settings_screen.dart'; +import 'package:gitjournal/settings.dart'; +import 'package:provider/provider.dart'; class SettingsDisplayImagesScreen extends StatefulWidget { @override @@ -29,11 +34,11 @@ class SettingsDisplayImagesScreen extends StatefulWidget { class SettingsDisplayImagesScreenState extends State { - final doNotThemeTagsKey = GlobalKey>(); - final doThemeTagsKey = GlobalKey>(); - @override Widget build(BuildContext context) { + final settings = Provider.of(context); + final theme = Theme.of(context); + final body = ListView(children: [ ListTile( title: Text(tr("settings.display.images.theming.title")), @@ -59,6 +64,49 @@ class SettingsDisplayImagesScreenState Navigator.of(context).push(route); }, ), + SettingsHeader(tr('settings.display.images.detailsView.header')), + ListTile( + title: Text(tr("settings.display.images.detailsView.maxZoom")), + subtitle: Row(children: [ + Expanded( + child: Slider.adaptive( + value: min(settings.maxImageZoom, 30), + onChanged: (v) { + setState(() { + settings.maxImageZoom = v == 30 ? double.infinity : v; + settings.save(); + }); + }, + max: 30, + min: 1, + activeColor: theme.accentColor, + inactiveColor: theme.disabledColor, + )), + Container( + width: 40, + child: settings.maxImageZoom == double.infinity + ? Icon( + Icons.all_inclusive, + color: theme.accentColor, + ) + : Text( + NumberFormat("##.0").format(settings.maxImageZoom), + style: theme.textTheme.subtitle2 + .copyWith(color: theme.accentColor), + textAlign: TextAlign.center, + )) + ])), + SwitchListTile( + title: Text( + tr('settings.display.images.detailsView.rotateGestures.title')), + subtitle: Text( + tr('settings.display.images.detailsView.rotateGestures.subtitle')), + value: settings.rotateImageGestures, + onChanged: (bool newVal) { + settings.rotateImageGestures = newVal; + settings.save(); + }, + ), ]); return Scaffold( diff --git a/lib/settings.dart b/lib/settings.dart index 158e04ec..6ad0f770 100644 --- a/lib/settings.dart +++ b/lib/settings.dart @@ -70,6 +70,10 @@ class Settings extends ChangeNotifier { SettingsHomeScreen homeScreen = SettingsHomeScreen.Default; SettingsTheme theme = SettingsTheme.Default; + // Display - Image + bool rotateImageGestures = false; + double maxImageZoom = 10; + // Display - Image - Theming bool themeRasterGraphics = false; SettingsImageTextType themeOverrideTagLocation = @@ -171,6 +175,11 @@ class Settings extends ChangeNotifier { SettingsHomeScreen.fromInternalString(_getString(pref, "homeScreen")); theme = SettingsTheme.fromInternalString(_getString(pref, "theme")); + // Display - Image + rotateImageGestures = + _getBool(pref, "rotateImageGestures") ?? rotateImageGestures; + maxImageZoom = _getDouble(pref, "maxImageZoom") ?? maxImageZoom; + // Display - Image - Theming themeRasterGraphics = _getBool(pref, "themeRasterGraphics") ?? themeRasterGraphics; @@ -244,6 +253,10 @@ class Settings extends ChangeNotifier { return pref.getInt(id + '_' + key); } + double _getDouble(SharedPreferences pref, String key) { + return pref.getDouble(id + '_' + key); + } + Future save() async { var pref = await SharedPreferences.getInstance(); var defaultSet = Settings(id); @@ -312,6 +325,12 @@ class Settings extends ChangeNotifier { await _setString(pref, "theme", theme.toInternalString(), defaultSet.theme.toInternalString()); + // Display - Image + await _setBool(pref, "rotateImageGestures", rotateImageGestures, + defaultSet.rotateImageGestures); + await _setDouble( + pref, "maxImageZoom", maxImageZoom, defaultSet.maxImageZoom); + // Display - Image - Theme await _setBool(pref, "themeRasterGraphics", themeRasterGraphics, defaultSet.themeRasterGraphics); @@ -427,6 +446,20 @@ class Settings extends ChangeNotifier { } } + Future _setDouble( + SharedPreferences pref, + String key, + double value, + double defaultValue, + ) async { + key = id + '_' + key; + if (value == defaultValue) { + await pref.remove(key); + } else { + await pref.setDouble(key, value); + } + } + Future _setStringSet( SharedPreferences pref, String key, @@ -471,6 +504,9 @@ class Settings extends ChangeNotifier { 'markdownLastUsedView': markdownLastUsedView.toInternalString(), 'homeScreen': homeScreen.toInternalString(), 'theme': theme.toInternalString(), + // Display - Image + 'rotateImageGestures': rotateImageGestures.toString(), + 'maxImageZoom': maxImageZoom.toString(), // Display - Image - Theming 'themeRasterGraphics': themeRasterGraphics.toString(), 'themeOverrideTagLocation': themeOverrideTagLocation.toInternalString(), diff --git a/lib/widgets/image_renderer.dart b/lib/widgets/image_renderer.dart deleted file mode 100644 index af2855db..00000000 --- a/lib/widgets/image_renderer.dart +++ /dev/null @@ -1,480 +0,0 @@ -/* -Copyright 2020-2021 Roland Fredenhagen - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import 'dart:io'; -import 'dart:ui'; - -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/widgets.dart'; - -import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; -import 'package:flutter_svg/flutter_svg.dart'; -import 'package:flutter_svg/svg.dart'; -import 'package:provider/provider.dart'; - -import 'package:gitjournal/settings.dart'; -import 'package:gitjournal/utils/hero_dialog.dart'; -import 'package:gitjournal/utils/logger.dart'; - -class ThemableImage extends StatelessWidget { - final double width; - final double height; - final String altText; - final String tooltip; - final Future data; - - ThemableImage._(this.data, this.width, this.height, altText, tooltip) - : altText = altText ?? "", - tooltip = tooltip ?? ""; - - factory ThemableImage(Uri uri, String imageDirectory, - {double width, double height, String altText, String titel}) { - final file = ((uri.isScheme("http") || uri.isScheme("https")) - ? DefaultCacheManager().getSingleFile(uri.toString()) - : Future.sync( - () => File.fromUri(Uri.parse(imageDirectory + uri.toString())))); - - final data = file.then( - (value) => value.path.endsWith(".svg") ? value.readAsString() : file); - - return ThemableImage._(data, width, height, altText, titel); - } - - @override - Widget build(BuildContext context) { - final settings = Provider.of(context); - final theme = Theme.of(context); - final dark = theme.brightness == Brightness.dark; - - ThemeOverride override = ThemeOverride.None; - if (settings.themeOverrideTagLocation == SettingsImageTextType.Alt || - settings.themeOverrideTagLocation == SettingsImageTextType.AltTool) { - if (hasTag(altText, settings.doThemeTags)) { - override = ThemeOverride.Do; - } else if (hasTag(altText, settings.doNotThemeTags)) { - override = ThemeOverride.No; - } - } - if (settings.themeOverrideTagLocation == SettingsImageTextType.Tooltip || - settings.themeOverrideTagLocation == SettingsImageTextType.AltTool) { - if (hasTag(tooltip, settings.doThemeTags)) { - override = ThemeOverride.Do; - } else if (hasTag(tooltip, settings.doNotThemeTags)) { - override = ThemeOverride.No; - } - } - - return LayoutBuilder( - builder: (context, constraints) { - final small = - constraints.maxWidth < MediaQuery.of(context).size.width - 40; - final image = FutureBuilder( - future: data, - builder: (context, snapshot) { - if (snapshot.hasError) { - String errorMessage = snapshot.error.toString(); - Log.e(errorMessage); - if (snapshot.error is HttpExceptionWithStatus) { - final httpError = snapshot.error as HttpExceptionWithStatus; - errorMessage = tr("widgets.imageRenderer.httpError", - namedArgs: { - "status": httpError.statusCode.toString(), - "url": httpError.uri.toString() - }); - } - return SizedBox( - width: width, - height: height, - child: Padding( - padding: const EdgeInsets.all(16), - child: Center( - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon( - Icons.error, - color: theme.errorColor, - size: 36, - ), - Text( - errorMessage, - style: theme.textTheme.bodyText1 - .copyWith(color: theme.errorColor), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ) - ]), - ), - ), - ); - } - - if (snapshot.hasData) { - Widget im; - if (snapshot.data is String) { - im = _handleSvg( - snapshot.data, width, height, context, override); - } else { - im = Image.file(snapshot.data, width: width, height: height); - if ((settings.themeRasterGraphics || - override == ThemeOverride.Do) && - override != ThemeOverride.No && - dark) { - im = themeFilter(im, theme.canvasColor); - } - } - - if (small) { - return GestureDetector( - child: Hero(tag: im, child: im), - onTap: () { - Navigator.push( - context, - HeroDialogRoute( - builder: (context) => GestureDetector( - child: Hero(tag: im, child: im), - onTap: () => Navigator.pop(context), - ), - )); - }, - ); - } - - return im; - } - - return SizedBox( - width: width, - height: height, - child: const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()))); - }); - - if (small || !settings.overlayCaption) { - final caption = _imageCaption(context, altText, tooltip, false); - return Column(children: [image, if (caption != null) caption]); - } - final caption = _imageCaption(context, altText, tooltip, true); - return Stack( - alignment: AlignmentDirectional.bottomEnd, - children: [image, if (caption != null) caption]); - }, - ); - } -} - -String _cleanCaption(BuildContext context, String caption) { - final settings = Provider.of(context); - final tags = [ - ...settings.doThemeTags, - ...settings.doNotThemeTags, - ...settings.doCaptionTags, - ...settings.doNotCaptionTags - ]; - return caption - .replaceAll( - RegExp( - r"\s*(?<=\s|\b)(" + - tags.map(RegExp.escape).join("|") + - r")(?=\s|\b)\s*", - caseSensitive: false), - " ") - .trim(); -} - -Widget _imageCaption( - BuildContext context, String altText, String tooltip, bool overlay) { - final theme = Theme.of(context); - final settings = Provider.of(context); - final dark = theme.brightness == Brightness.dark; - - bool altTextCaption = - settings.useAsCaption == SettingsImageTextType.AltTool || - settings.useAsCaption == SettingsImageTextType.AltTool; - if (hasTag(altText, settings.doCaptionTags)) { - altTextCaption = true; - } else if (hasTag(altText, settings.doNotCaptionTags)) { - altTextCaption = false; - } - - bool tooltipCaption = - settings.useAsCaption == SettingsImageTextType.Tooltip || - settings.useAsCaption == SettingsImageTextType.AltTool; - if (hasTag(tooltip, settings.doCaptionTags)) { - tooltipCaption = true; - } else if (hasTag(tooltip, settings.doNotCaptionTags)) { - tooltipCaption = false; - } - - altText = altTextCaption ? _cleanCaption(context, altText) : ""; - tooltip = tooltipCaption ? _cleanCaption(context, tooltip) : ""; - String text = ""; - if (altText.isNotEmpty && tooltip.isNotEmpty) { - text = tr("widgets.imageRenderer.caption", - namedArgs: settings.tooltipFirst - ? {"first": tooltip, "second": altText} - : {"first": altText, "second": tooltip}); - } else { - text = altText + tooltip; - } - - if (text.isNotEmpty) { - if (!overlay) { - return Text(text, style: theme.textTheme.caption); - } - - return LayoutBuilder(builder: (context, constraints) { - final maxWidth = constraints.constrainWidth(200); - const padding = 6.0, margin = 4.0, borderRadius = 5.0; - final blur = settings.blurBehindCaption ? 2.0 : 0.0; - - final overflown = (TextPainter( - text: TextSpan(text: text), - maxLines: 2, - textScaleFactor: MediaQuery.of(context).textScaleFactor, - textDirection: Directionality.of(context)) - ..layout(maxWidth: maxWidth - 2 * (padding + margin))) - .didExceedMaxLines; - - final bColor = settings.transparentCaption - ? (dark ? Colors.black : Colors.white).withAlpha(150) - : theme.canvasColor; - final box = ClipRRect( - borderRadius: BorderRadius.circular(borderRadius), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), - child: Container( - color: bColor, - child: Padding( - padding: const EdgeInsets.all(padding), - child: Text( - text, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: theme.textTheme.bodyText2, - )), - ), - ), - ); - if (!overflown) { - return ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth), - child: Container(margin: const EdgeInsets.all(margin), child: box)); - } - - final caption = Hero(tag: "caption", child: box); - - return ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth), - child: Padding( - padding: const EdgeInsets.all(margin), - child: GestureDetector( - onTap: () { - Navigator.push(context, HeroDialogRoute(builder: (context) { - return Dialog( - backgroundColor: Colors.transparent, - child: GestureDetector( - onTap: () { - Navigator.pop(context); - }, - child: caption, - )); - })); - }, - child: caption, - ), - ), - ); - }); - } - - return null; -} - -Widget _handleSvg(final String string, double width, final double height, - final BuildContext context, final ThemeOverride override) { - final settings = Provider.of(context); - final theme = Theme.of(context); - width ??= MediaQuery.of(context).size.width; - final dark = theme.brightness == Brightness.dark; - if (settings.themeVectorGraphics == SettingsThemeVectorGraphics.Off && - override != ThemeOverride.Do || - override == ThemeOverride.No) { - return SvgPicture.string(string, width: width, height: height); - } - if (settings.themeVectorGraphics == SettingsThemeVectorGraphics.Filter && - dark) { - return themeFilter(SvgPicture.string(string, width: width, height: height), - theme.canvasColor); - } - final transformColor = dark - ? themeDark(theme.canvasColor, settings.vectorGraphicsAdjustColors) - : settings.matchCanvasColor - ? whiteToCanvas(theme.canvasColor) - : noTheme; - return SvgPicture( - StringPicture((data, colorFilter, key) async { - DrawableRoot svgRoot = await svg.fromSvgString(data, key); - if (settings.themeSvgWithBackground || - override == ThemeOverride.Do || - !hasBackground(svgRoot, svgRoot.viewport.viewBox.width, - svgRoot.viewport.viewBox.height)) { - svgRoot = themeDrawable(svgRoot, transformColor); - } - final Picture pic = svgRoot.toPicture( - clipToViewBox: false, - colorFilter: colorFilter, - ); - return PictureInfo( - picture: pic, - viewport: svgRoot.viewport.viewBoxRect, - size: svgRoot.viewport.size, - ); - }, - string + - ''), - width: width, - height: height, - ); -} - -Color Function(Color color) whiteToCanvas(Color canvasColor) => - (Color color) => color.value == 0xffffffff ? canvasColor : color; - -Color noTheme(Color color) => color; - -Color Function(Color color) themeDark( - Color canvasColor, SettingsVectorGraphicsAdjustColors adjustColors) => - (Color color) { - final hslColor = HSLColor.fromColor(color); - final backGroundLightness = HSLColor.fromColor(canvasColor).lightness; - if (adjustColors == SettingsVectorGraphicsAdjustColors.BnW) { - if (hslColor.lightness > 0.95 && hslColor.saturation < 0.02) { - return HSLColor.fromAHSL(hslColor.alpha, 0, 0, backGroundLightness) - .toColor(); - } - if (hslColor.lightness < 0.02) { - return HSLColor.fromAHSL(hslColor.alpha, 0, 0, 1).toColor(); - } - return color; - } - if (adjustColors == SettingsVectorGraphicsAdjustColors.Grays) { - if (hslColor.saturation < 0.02 || hslColor.lightness < 0.02) { - return HSLColor.fromAHSL(hslColor.alpha, 0, 0, - 1 - (hslColor.lightness * (1 - backGroundLightness))) - .toColor(); - } - return color; - } - - return HSLColor.fromAHSL( - hslColor.alpha, - hslColor.hue, - hslColor.saturation, - 1 - (hslColor.lightness * (1 - backGroundLightness))) - .toColor(); - }; - -bool hasBackground(Drawable draw, double width, double height) { - if (draw is DrawableShape) { - final drawShape = draw; - return drawShape.style.fill != null && - drawShape.style.fill.color.alpha > 0.99 && - [ - Offset(0 + width * 0.01, 0 + height * 0.01), - Offset(width - width * 0.01, 0 + height * 0.01), - Offset(width - width * 0.01, height - height * 0.01), - Offset(0 + width * 0.01, height - height * 0.01), - ].every(drawShape.path.contains); - } - if (draw is DrawableParent) { - final drawParent = draw; - return drawParent.children - .any((element) => hasBackground(element, width, height)); - } - return false; -} - -bool hasTag(String text, Set tags) { - return tags - .map((e) => RegExp(r"(?<=\s|\b)" + RegExp.escape(e) + r"(?=\s|\b)", - caseSensitive: false)) - .any((e) => e.hasMatch(text)); -} - -Drawable themeDrawable( - Drawable draw, Color Function(Color color) transformColor) { - if (draw is DrawableStyleable && !(draw is DrawableGroup)) { - final DrawableStyleable drawStylable = draw; - draw = drawStylable.mergeStyle(DrawableStyle( - stroke: drawStylable.style.stroke != null && - drawStylable.style.stroke.color != null - ? DrawablePaint.merge( - DrawablePaint(drawStylable.style.stroke.style, - color: transformColor(drawStylable.style.stroke.color)), - drawStylable.style.stroke) - : null, - fill: drawStylable.style.fill != null && - drawStylable.style.fill.color != null - ? DrawablePaint.merge( - DrawablePaint(drawStylable.style.fill.style, - color: transformColor(drawStylable.style.fill.color)), - drawStylable.style.fill) - : null)); - } - if (draw is DrawableParent) { - final DrawableParent drawParent = draw; - final children = drawParent.children - .map((e) => themeDrawable(e, transformColor)) - .toList(growable: false); - if (draw is DrawableRoot) { - final DrawableRoot drawRoot = draw; - draw = DrawableRoot(drawRoot.id, drawRoot.viewport, children, - drawRoot.definitions, drawRoot.style, - transform: drawRoot.transform); - } else if (draw is DrawableGroup) { - final DrawableGroup drawGroup = draw; - draw = DrawableGroup(drawGroup.id, children, drawGroup.style, - transform: drawGroup.transform); - } - } - return draw; -} - -Widget themeFilter(Widget widget, Color background) { - final lightness = (1 - HSLColor.fromColor(background).lightness) * 255; - // TODO Switch to HSL Filter, when availible (https://github.com/flutter/flutter/issues/76729) - final stack = ColorFiltered( - colorFilter: ColorFilter.matrix([ - -(lightness / 255), 0, 0, 0, 255, // R - 0, -(lightness / 255), 0, 0, 255, // G - 0, 0, -(lightness / 255), 0, 255, // B - 0, 0, 0, 1, 0 // A - ]), - child: widget); - return stack; -} - -enum ThemeOverride { None, Do, No } diff --git a/lib/widgets/images/image_caption.dart b/lib/widgets/images/image_caption.dart new file mode 100644 index 00000000..edddfc4a --- /dev/null +++ b/lib/widgets/images/image_caption.dart @@ -0,0 +1,178 @@ +/* +Copyright 2020-2021 Roland Fredenhagen + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import 'dart:ui'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:gitjournal/settings.dart'; +import 'package:gitjournal/utils/hero_dialog.dart'; +import 'package:provider/provider.dart'; + +class ImageCaption extends StatelessWidget { + final String altText; + final String tooltip; + final bool overlay; + ImageCaption(this.altText, this.tooltip, this.overlay); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final settings = Provider.of(context); + + final text = captionText(context, altText, tooltip); + + if (!overlay) { + return Text(text, style: theme.textTheme.caption); + } + + return LayoutBuilder(builder: (context, constraints) { + final maxWidth = constraints.constrainWidth(200); + const padding = 6.0, margin = 4.0, borderRadius = 5.0; + final blur = settings.blurBehindCaption ? 2.0 : 0.0; + + final overflown = (TextPainter( + text: TextSpan(text: text), + maxLines: 2, + textScaleFactor: MediaQuery.of(context).textScaleFactor, + textDirection: Directionality.of(context)) + ..layout(maxWidth: maxWidth - 2 * (padding + margin))) + .didExceedMaxLines; + + final box = ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), + child: Container( + color: _overlayBackgroundColor(context), + child: Padding( + padding: const EdgeInsets.all(padding), + child: Text( + text, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyText2, + )), + ), + ), + ); + if (!overflown) { + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Container(margin: const EdgeInsets.all(margin), child: box)); + } + + final caption = Hero(tag: "caption", child: box); + + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Padding( + padding: const EdgeInsets.all(margin), + child: GestureDetector( + onTap: () { + Navigator.push(context, HeroDialogRoute(builder: (context) { + return Dialog( + backgroundColor: Colors.transparent, + child: GestureDetector( + onTap: () { + Navigator.pop(context); + }, + child: caption, + )); + })); + }, + child: caption, + ), + ), + ); + }); + } +} + +bool hasTag(String text, Set tags) { + return tags + .map((e) => RegExp(r"(?<=^|\s|\b)" + RegExp.escape(e) + r"(?=$|\s|\b)", + caseSensitive: false)) + .any((e) => e.hasMatch(text)); +} + +bool shouldCaption(BuildContext context, String altText, String tooltip) { + return captionText(context, altText, tooltip).isNotEmpty; +} + +String captionText(BuildContext context, String altText, String tooltip) { + final settings = Provider.of(context); + + bool altTextCaption = + settings.useAsCaption == SettingsImageTextType.AltTool || + settings.useAsCaption == SettingsImageTextType.AltTool; + if (hasTag(altText, settings.doCaptionTags)) { + altTextCaption = true; + } else if (hasTag(altText, settings.doNotCaptionTags)) { + altTextCaption = false; + } + + bool tooltipCaption = + settings.useAsCaption == SettingsImageTextType.Tooltip || + settings.useAsCaption == SettingsImageTextType.AltTool; + if (hasTag(tooltip, settings.doCaptionTags)) { + tooltipCaption = true; + } else if (hasTag(tooltip, settings.doNotCaptionTags)) { + tooltipCaption = false; + } + + String _altText = altTextCaption ? _cleanCaption(context, altText) : ""; + String _tooltip = tooltipCaption ? _cleanCaption(context, tooltip) : ""; + String text = ""; + if (_altText.isNotEmpty && _tooltip.isNotEmpty) { + text = tr("widgets.imageRenderer.caption", + namedArgs: settings.tooltipFirst + ? {"first": _tooltip, "second": _altText} + : {"first": _altText, "second": _tooltip}); + } else { + text = _altText + _tooltip; + } + + return text; +} + +String _cleanCaption(BuildContext context, String caption) { + final settings = Provider.of(context); + final tags = [ + ...settings.doThemeTags, + ...settings.doNotThemeTags, + ...settings.doCaptionTags, + ...settings.doNotCaptionTags + ]; + return caption + .replaceAll( + RegExp( + r"\s*(?<=^|\s|\b)(" + + tags.map(RegExp.escape).join("|") + + r")(?=$|\s|\b)\s*", + caseSensitive: false), + " ") + .trim(); +} + +Color _overlayBackgroundColor(context) { + final settings = Provider.of(context); + final theme = Theme.of(context); + return settings.transparentCaption + ? (theme.brightness == Brightness.dark ? Colors.black : Colors.white) + .withAlpha(100) + : theme.canvasColor; +} diff --git a/lib/widgets/images/image_details.dart b/lib/widgets/images/image_details.dart new file mode 100644 index 00000000..94e43d8f --- /dev/null +++ b/lib/widgets/images/image_details.dart @@ -0,0 +1,106 @@ +/* +Copyright 2020-2021 Roland Fredenhagen + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import 'package:flutter/material.dart'; +import 'package:gitjournal/settings.dart'; +import 'package:gitjournal/widgets/images/markdown_image.dart'; +import 'package:gitjournal/widgets/images/themable_image.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:provider/provider.dart'; + +class ImageDetails extends StatefulWidget { + final ThemableImage image; + final String caption; + ImageDetails(this.image, this.caption); + + @override + _ImageDetailsState createState() => _ImageDetailsState(); +} + +class _ImageDetailsState extends State { + int rotation = 0; + bool showUI = true; + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final settings = Provider.of(context); + final bg = + theme.brightness == Brightness.dark ? Colors.black : Colors.white; + final overlayColor = getOverlayBackgroundColor(context, + light: Colors.white, dark: Colors.black); + return Stack( + children: [ + PhotoView.customChild( + backgroundDecoration: BoxDecoration(color: bg), + child: RotatedBox( + quarterTurns: rotation, + child: ThemableImage.from(widget.image, bg: bg)), + minScale: 1.0, + maxScale: settings.maxImageZoom, + heroAttributes: PhotoViewHeroAttributes(tag: widget.image), + onTapUp: (context, details, controllerValue) => + setState(() => showUI = !showUI), + enableRotation: settings.rotateImageGestures, + ), + if (showUI) + Positioned( + top: MediaQuery.of(context).padding.top, + left: 0, + right: 0, + height: 60, + child: Material( + color: overlayColor, + child: Padding( + padding: const EdgeInsetsDirectional.fromSTEB(8, 0, 0, 0), + child: Row( + children: [ + IconButton( + splashRadius: 20, + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.pop(context)), + const Spacer(), + IconButton( + splashRadius: 20, + icon: const Icon(Icons.rotate_90_degrees_ccw), + onPressed: () => setState(() => rotation--)) + ], + )))), + // TODO use a DraggableScrollableSheet, when they can be dynamically + // height restricted https://github.com/flutter/flutter/issues/41599 + if (showUI && widget.caption.isNotEmpty) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Hero( + tag: "caption", + child: Container( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: SingleChildScrollView( + child: Text( + widget.caption, + style: theme.primaryTextTheme.bodyText1, + )), + ), + color: overlayColor, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ))) + ], + ); + } +} diff --git a/lib/widgets/images/markdown_image.dart b/lib/widgets/images/markdown_image.dart new file mode 100644 index 00000000..45a1bdc0 --- /dev/null +++ b/lib/widgets/images/markdown_image.dart @@ -0,0 +1,227 @@ +/* +Copyright 2020-2021 Roland Fredenhagen + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import 'dart:io'; +import 'dart:ui'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:gitjournal/widgets/images/image_caption.dart'; +import 'package:gitjournal/widgets/images/image_details.dart'; +import 'package:gitjournal/widgets/images/themable_image.dart'; +import 'package:provider/provider.dart'; + +import 'package:gitjournal/utils/logger.dart'; +import 'package:gitjournal/settings.dart'; + +class MarkdownImage extends StatelessWidget { + final double width; + final double height; + final String altText; + final String tooltip; + final Future data; + + MarkdownImage._( + this.data, this.width, this.height, String altText, String tooltip) + : altText = altText ?? "", + tooltip = tooltip ?? ""; + + factory MarkdownImage(Uri uri, String imageDirectory, + {double width, double height, String altText, String titel}) { + final file = ((uri.isScheme("http") || uri.isScheme("https")) + ? DefaultCacheManager().getSingleFile(uri.toString()) + : Future.sync( + () => File.fromUri(Uri.parse(imageDirectory + uri.toString())))); + + final data = file.then( + (value) => value.path.endsWith(".svg") ? value.readAsString() : file); + + return MarkdownImage._(data, width, height, altText, titel); + } + + @override + Widget build(BuildContext context) { + final settings = Provider.of(context); + final theme = Theme.of(context); + final dark = theme.brightness == Brightness.dark; + + // Test for override tags in AltText/Tooltip + ThemeOverride override = ThemeOverride.None; + if (settings.themeOverrideTagLocation == SettingsImageTextType.Alt || + settings.themeOverrideTagLocation == SettingsImageTextType.AltTool) { + if (hasTag(altText, settings.doThemeTags)) { + override = ThemeOverride.Do; + } else if (hasTag(altText, settings.doNotThemeTags)) { + override = ThemeOverride.No; + } + } + if (settings.themeOverrideTagLocation == SettingsImageTextType.Tooltip || + settings.themeOverrideTagLocation == SettingsImageTextType.AltTool) { + if (hasTag(tooltip, settings.doThemeTags)) { + override = ThemeOverride.Do; + } else if (hasTag(tooltip, settings.doNotThemeTags)) { + override = ThemeOverride.No; + } + } + + return LayoutBuilder( + builder: (context, constraints) { + final small = + constraints.maxWidth < MediaQuery.of(context).size.width - 40; + final image = FutureBuilder( + future: data, + builder: (context, snapshot) { + if (snapshot.hasError) { + String errorMessage = snapshot.error.toString(); + Log.e(errorMessage); + if (snapshot.error is HttpExceptionWithStatus) { + final httpError = snapshot.error as HttpExceptionWithStatus; + errorMessage = tr("widgets.imageRenderer.httpError", + namedArgs: { + "status": httpError.statusCode.toString(), + "url": httpError.uri.toString() + }); + } + return SizedBox( + width: width, + height: height, + child: Padding( + padding: const EdgeInsets.all(16), + child: Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.error, + color: theme.errorColor, + size: 36, + ), + Text( + errorMessage, + style: theme.textTheme.bodyText1 + .copyWith(color: theme.errorColor), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ) + ]), + ), + ), + ); + } + + if (snapshot.hasData) { + Widget im; + if (snapshot.data is String) { + im = ThemableImage.svg( + snapshot.data, + width: width ?? MediaQuery.of(context).size.width, + height: height, + themingMethod: override == ThemeOverride.No || + settings.themeVectorGraphics == + SettingsThemeVectorGraphics.Off && + override != ThemeOverride.Do + ? ThemingMethod.none + : dark + ? settings.themeVectorGraphics == + SettingsThemeVectorGraphics.Filter + ? ThemingMethod.filter + : ThemingMethod.invertBrightness + : settings.matchCanvasColor + ? ThemingMethod.wToBg + : ThemingMethod.none, + themingCondition: settings.themeSvgWithBackground || + override == ThemeOverride.Do + ? ThemingCondition.none + : ThemingCondition.noBackground, + colorCondition: settings.vectorGraphicsAdjustColors == + SettingsVectorGraphicsAdjustColors.All + ? ColorCondition.all + : settings.vectorGraphicsAdjustColors == + SettingsVectorGraphicsAdjustColors.BnW + ? ColorCondition.bw + : ColorCondition.gray, + bg: settings.matchCanvasColor + ? theme.canvasColor + : Colors.black, + ); + } else { + im = ThemableImage.image( + snapshot.data, + width: width, + height: height, + doTheme: (settings.themeRasterGraphics || + override == ThemeOverride.Do) && + override != ThemeOverride.No && + dark, + bg: theme.canvasColor, + ); + } + + return GestureDetector( + child: Hero(tag: im, child: im), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => ImageDetails( + im, captionText(context, altText, tooltip)))); + }, + ); + } + + return SizedBox( + width: width, + height: height, + child: const Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()))); + }); + + if (shouldCaption(context, altText, tooltip)) { + if (small || !settings.overlayCaption) { + return Column( + children: [image, ImageCaption(altText, tooltip, false)]); + } else { + return Stack( + alignment: AlignmentDirectional.bottomEnd, + children: [image, ImageCaption(altText, tooltip, true)]); + } + } + return image; + }, + ); + } +} + +Color getOverlayBackgroundColor(BuildContext context, + {Color light, Color dark}) { + final settings = Provider.of(context); + final theme = Theme.of(context); + return theme.brightness == Brightness.dark + ? settings.transparentCaption + ? Colors.black.withAlpha(100) + : dark ?? theme.canvasColor + : settings.transparentCaption + ? Colors.white.withAlpha(100) + : light ?? theme.canvasColor; +} + +enum ThemeOverride { None, Do, No } diff --git a/lib/widgets/images/themable_image.dart b/lib/widgets/images/themable_image.dart new file mode 100644 index 00000000..e8b484b7 --- /dev/null +++ b/lib/widgets/images/themable_image.dart @@ -0,0 +1,248 @@ +/* +Copyright 2020-2021 Roland Fredenhagen + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import 'dart:io'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:flutter_svg/svg.dart'; + +class ThemableImage extends StatelessWidget { + final double width; + final double height; + final File file; + final String string; + final ThemingMethod themingMethod; + final ThemingCondition themingCondition; + final ColorCondition colorCondition; + final Color bg; + + ThemableImage.image( + this.file, { + this.width, + this.height, + doTheme = false, + this.bg = Colors.white, + }) : string = "", + themingMethod = doTheme ? ThemingMethod.filter : ThemingMethod.none, + themingCondition = ThemingCondition.none, + colorCondition = ColorCondition.all; + + ThemableImage.svg( + this.string, { + this.width, + this.height, + this.themingMethod = ThemingMethod.none, + this.bg = Colors.white, + this.themingCondition = ThemingCondition.none, + this.colorCondition = ColorCondition.all, + }) : file = null; + + ThemableImage.from( + ThemableImage ti, { + double width, + double height, + ThemingMethod themingMethod, + ThemingCondition themingCondition, + ColorCondition colorCondition, + Color bg, + }) : file = ti.file, + string = ti.string, + width = width ?? ti.width, + height = height ?? ti.height, + themingMethod = themingMethod ?? ti.themingMethod, + themingCondition = themingCondition ?? ti.themingCondition, + colorCondition = colorCondition ?? ti.colorCondition, + bg = bg ?? ti.bg; + + @override + Widget build(BuildContext context) { + Widget image; + if (file != null) { + image = Image.file(file, width: width, height: height); + } else if (string.isNotEmpty) { + image = SvgPicture( + StringPicture( + _transformSVG, + string + + ''), + width: width, + height: height); + } else { + throw Exception("Tried to render an image without File or SVG string"); + } + return themingMethod == ThemingMethod.filter + ? _themeFilter(image, bg) + : image; + } + + Future _transformSVG(data, colorFilter, key) async { + DrawableRoot svgRoot = await svg.fromSvgString(data, key); + if (themingCondition != ThemingCondition.noBackground || + !_hasBackground(svgRoot, svgRoot.viewport.viewBox.width, + svgRoot.viewport.viewBox.height) || + themingMethod == ThemingMethod.wToBg) { + svgRoot = _themeDrawable(svgRoot, (Color color) { + switch (themingMethod) { + case ThemingMethod.wToBg: + return color == Colors.white ? bg : color; + + case ThemingMethod.none: + case ThemingMethod.filter: + return color; + + case ThemingMethod.invertBrightness: + final hslColor = HSLColor.fromColor(color); + final backGroundLightness = HSLColor.fromColor(bg).lightness; + switch (colorCondition) { + case ColorCondition.all: + return HSLColor.fromAHSL( + hslColor.alpha, + hslColor.hue, + hslColor.saturation, + 1 - (hslColor.lightness * (1 - backGroundLightness))) + .toColor(); + + case ColorCondition.bw: + if (hslColor.lightness > 0.95 && hslColor.saturation < 0.02) { + return HSLColor.fromAHSL( + hslColor.alpha, 0, 0, backGroundLightness) + .toColor(); + } + return color; + + case ColorCondition.gray: + if (hslColor.saturation < 0.02 || hslColor.lightness < 0.02) { + return HSLColor.fromAHSL(hslColor.alpha, 0, 0, + 1 - (hslColor.lightness * (1 - backGroundLightness))) + .toColor(); + } + return color; + } + } + return color; + }); + } + + final Picture pic = svgRoot.toPicture( + clipToViewBox: false, + colorFilter: colorFilter, + ); + + return PictureInfo( + picture: pic, + viewport: svgRoot.viewport.viewBoxRect, + size: svgRoot.viewport.size, + ); + } +} + +enum ThemingMethod { none, filter, wToBg, invertBrightness } + +enum ThemingCondition { none, noBackground } + +enum ColorCondition { all, bw, gray } + +/// Tests if the [Drawable] [draw] has a non transparent background +/// +/// [width] and [height] specify the area for a drawable to fill to be a valid background. +/// Use [minAlpha] and [maxBorder] to accept imperfect Backgrounds. +/// [maxBorder] is the fraction of the [width]/[height] that does not need to be filled. +/// Use [maxDepth] to controll how deep the Tree is traversed to find a background, +/// `maxDepth = -1` will traverse the whole Tree. +bool _hasBackground(Drawable draw, double width, double height, + {minAlpha = 0.99, maxBorder = 0.01, maxDepth = 10}) { + if (maxDepth == 0) { + if (draw is DrawableShape) { + final drawShape = draw; + return drawShape.style.fill != null && + drawShape.style.fill.color.alpha > minAlpha && + [ + Offset(width * maxBorder, height * maxBorder), + Offset(width - width * maxBorder, height * maxBorder), + Offset(width - width * maxBorder, height - height * maxBorder), + Offset(width * maxBorder, height - height * maxBorder), + ].every(drawShape.path.contains); + } + // TODO Allow for two shapes to be the background together + if (draw is DrawableParent) { + final drawParent = draw; + return drawParent.children.any((element) => _hasBackground( + element, width, height, + minAlpha: minAlpha, maxBorder: maxBorder, maxDepth: maxDepth - 1)); + } + } + return false; +} + +Drawable _themeDrawable( + Drawable draw, Color Function(Color color) transformColor) { + if (draw is DrawableStyleable && !(draw is DrawableGroup)) { + final DrawableStyleable drawStylable = draw; + draw = drawStylable.mergeStyle(DrawableStyle( + stroke: drawStylable.style.stroke != null && + drawStylable.style.stroke.color != null + ? DrawablePaint.merge( + DrawablePaint(drawStylable.style.stroke.style, + color: transformColor(drawStylable.style.stroke.color)), + drawStylable.style.stroke) + : null, + fill: drawStylable.style.fill != null && + drawStylable.style.fill.color != null + ? DrawablePaint.merge( + DrawablePaint(drawStylable.style.fill.style, + color: transformColor(drawStylable.style.fill.color)), + drawStylable.style.fill) + : null)); + } + if (draw is DrawableParent) { + final DrawableParent drawParent = draw; + final children = drawParent.children + .map((e) => _themeDrawable(e, transformColor)) + .toList(growable: false); + if (draw is DrawableRoot) { + final DrawableRoot drawRoot = draw; + draw = DrawableRoot(drawRoot.id, drawRoot.viewport, children, + drawRoot.definitions, drawRoot.style, + transform: drawRoot.transform); + } else if (draw is DrawableGroup) { + final DrawableGroup drawGroup = draw; + draw = DrawableGroup(drawGroup.id, children, drawGroup.style, + transform: drawGroup.transform); + } + } + return draw; +} + +Widget _themeFilter(Widget widget, Color background) { + final lightness = (1 - HSLColor.fromColor(background).lightness) * 255; + // TODO Switch to HSL Filter, when available (https://github.com/flutter/flutter/issues/76729) + final stack = ColorFiltered( + colorFilter: ColorFilter.matrix([ + -(lightness / 255), 0, 0, 0, 255, // R + 0, -(lightness / 255), 0, 0, 255, // G + 0, 0, -(lightness / 255), 0, 255, // B + 0, 0, 0, 1, 0 // A + ]), + child: widget); + return stack; +} diff --git a/lib/widgets/markdown_renderer.dart b/lib/widgets/markdown_renderer.dart index bbb0a610..a36ee4e3 100644 --- a/lib/widgets/markdown_renderer.dart +++ b/lib/widgets/markdown_renderer.dart @@ -31,7 +31,7 @@ import 'package:gitjournal/folder_views/common.dart'; import 'package:gitjournal/utils.dart'; import 'package:gitjournal/utils/link_resolver.dart'; import 'package:gitjournal/utils/logger.dart'; -import 'package:gitjournal/widgets/image_renderer.dart'; +import 'package:gitjournal/widgets/images/markdown_image.dart'; class MarkdownRenderer extends StatelessWidget { final Note note; @@ -117,7 +117,7 @@ class MarkdownRenderer extends StatelessWidget { ); } }, - imageBuilder: (url, title, alt) => ThemableImage( + imageBuilder: (url, title, alt) => MarkdownImage( url, note.parent.folderPath + p.separator, titel: title, altText: alt), extensionSet: markdownExtensions(), diff --git a/pubspec.lock b/pubspec.lock index 771636b7..f1b9c8f0 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -771,6 +771,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.0.2" + photo_view: + dependency: "direct main" + description: + name: photo_view + url: "https://pub.dartlang.org" + source: hosted + version: "0.10.3" platform: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 0f17fb44..37534d7c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -59,6 +59,7 @@ dependencies: package_info: ">=0.4.1 <2.0.0" path: ^1.6.2 permission_handler: ^6.1.1 + photo_view: ^0.10.3 provider: ^4.3.2+2 quick_actions: ^0.4.0+10 receive_sharing_intent: ^1.4.0+2