feat(image): Added Details view for Images

Users can zoom, quarter-rotate and freely rotate Images

Updated flutterw

Refs GitJournal#410 GitJournal#386
This commit is contained in:
Roland Fredenhagen
2021-03-25 14:15:14 +01:00
committed by Roland Fredenhagen
parent 5afe819aa4
commit e8fd11c85b
12 changed files with 863 additions and 486 deletions

View File

@ -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

View File

@ -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<SettingsDisplayImagesScreen> {
final doNotThemeTagsKey = GlobalKey<FormFieldState<String>>();
final doThemeTagsKey = GlobalKey<FormFieldState<String>>();
@override
Widget build(BuildContext context) {
final settings = Provider.of<Settings>(context);
final theme = Theme.of(context);
final body = ListView(children: <Widget>[
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(

View File

@ -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<void> 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<void> _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<void> _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(),

View File

@ -1,480 +0,0 @@
/*
Copyright 2020-2021 Roland Fredenhagen <important@van-fredenhagen.de>
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<dynamic> 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<Settings>(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<Settings>(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<Settings>(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<Settings>(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 +
'<?theme darkMode="$dark" ' +
'override="$override" ' +
'opaqueBackground="${settings.themeSvgWithBackground}" ' +
'whiteToCanvas="${settings.matchCanvasColor}" ' +
'adjustColors="${settings.vectorGraphicsAdjustColors.toInternalString()}"?>'),
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<String> 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(<double>[
-(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 }

View File

@ -0,0 +1,178 @@
/*
Copyright 2020-2021 Roland Fredenhagen <important@van-fredenhagen.de>
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<Settings>(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<String> 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<Settings>(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<Settings>(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<Settings>(context);
final theme = Theme.of(context);
return settings.transparentCaption
? (theme.brightness == Brightness.dark ? Colors.black : Colors.white)
.withAlpha(100)
: theme.canvasColor;
}

View File

@ -0,0 +1,106 @@
/*
Copyright 2020-2021 Roland Fredenhagen <important@van-fredenhagen.de>
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<ImageDetails> {
int rotation = 0;
bool showUI = true;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final settings = Provider.of<Settings>(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),
)))
],
);
}
}

View File

@ -0,0 +1,227 @@
/*
Copyright 2020-2021 Roland Fredenhagen <important@van-fredenhagen.de>
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<dynamic> 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<Settings>(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<Settings>(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 }

View File

@ -0,0 +1,248 @@
/*
Copyright 2020-2021 Roland Fredenhagen <important@van-fredenhagen.de>
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 +
'<?theme '
'themingMethod="$themingMethod" '
'themingCondition="$themingCondition" '
'colorCondition="$colorCondition" '
'backgroundColor="$bg" '
'?>'),
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<PictureInfo> _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(<double>[
-(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;
}

View File

@ -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(),

View File

@ -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:

View File

@ -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