mirror of
https://github.com/GitJournal/GitJournal.git
synced 2025-06-27 17:29:50 +08:00
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:

committed by
Roland Fredenhagen

parent
5afe819aa4
commit
e8fd11c85b
2
.flutter
2
.flutter
Submodule .flutter updated: 9b2d32b605...4d7946a68d
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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(),
|
||||
|
@ -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 }
|
178
lib/widgets/images/image_caption.dart
Normal file
178
lib/widgets/images/image_caption.dart
Normal 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;
|
||||
}
|
106
lib/widgets/images/image_details.dart
Normal file
106
lib/widgets/images/image_details.dart
Normal 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),
|
||||
)))
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
227
lib/widgets/images/markdown_image.dart
Normal file
227
lib/widgets/images/markdown_image.dart
Normal 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 }
|
248
lib/widgets/images/themable_image.dart
Normal file
248
lib/widgets/images/themable_image.dart
Normal 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;
|
||||
}
|
@ -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(),
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user