improved performance and battery usage on older devices
This commit is contained in:
@@ -8,6 +8,7 @@ import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../storage/chat_background_settings.dart';
|
||||
import '../theming/app_theme.dart';
|
||||
import '../utils/app_paths.dart';
|
||||
import '../utils/screen_bound_image.dart';
|
||||
|
||||
/// Renders the configurable chat background behind [child].
|
||||
///
|
||||
@@ -24,7 +25,13 @@ class ChatBackground extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = context.watch<SettingsCubit>().val().chatBackgroundSettings;
|
||||
// Value snapshot: the settings object is mutated in place, so only copied
|
||||
// values can tell whether the background actually changed.
|
||||
context.select((SettingsCubit c) {
|
||||
final s = c.state.chatBackgroundSettings;
|
||||
return (s.type, s.fit, s.colorValue, s.imageVersion, s.dim, s.blur);
|
||||
});
|
||||
final s = context.read<SettingsCubit>().val().chatBackgroundSettings;
|
||||
final dark = AppTheme.isDarkMode(context);
|
||||
|
||||
final Widget background;
|
||||
@@ -32,7 +39,9 @@ class ChatBackground extends StatelessWidget {
|
||||
case ChatBackgroundType.none:
|
||||
background = ColoredBox(color: Theme.of(context).colorScheme.surface);
|
||||
case ChatBackgroundType.color:
|
||||
background = ColoredBox(color: Color(s.colorValue ?? _fallbackColor.toARGB32()));
|
||||
background = ColoredBox(
|
||||
color: Color(s.colorValue ?? _fallbackColor.toARGB32()),
|
||||
);
|
||||
case ChatBackgroundType.pattern:
|
||||
background = _imageLayer(
|
||||
const AssetImage('assets/background/chat.png'),
|
||||
@@ -41,12 +50,17 @@ class ChatBackground extends StatelessWidget {
|
||||
isPattern: true,
|
||||
);
|
||||
case ChatBackgroundType.image:
|
||||
final image = FileImage(File(AppPaths.chatBackgroundImage));
|
||||
background = KeyedSubtree(
|
||||
// imageVersion changes on every replacement, forcing a fresh subtree
|
||||
// alongside the explicit ImageCache evict in the settings handler.
|
||||
key: ValueKey(s.imageVersion),
|
||||
// Only "cover" scales the photo to the screen; tile/center render
|
||||
// it unscaled, so those keep the natural size.
|
||||
child: _imageLayer(
|
||||
FileImage(File(AppPaths.chatBackgroundImage)),
|
||||
s.fit == ChatBackgroundFit.cover
|
||||
? screenBoundImage(context, image)
|
||||
: image,
|
||||
s,
|
||||
dark,
|
||||
isPattern: false,
|
||||
@@ -57,7 +71,9 @@ class ChatBackground extends StatelessWidget {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(child: background),
|
||||
// Own layer: without it every scroll frame of the message list above
|
||||
// repaints the (possibly tiled or blurred) background as well.
|
||||
Positioned.fill(child: RepaintBoundary(child: background)),
|
||||
if (s.dim > 0)
|
||||
Positioned.fill(
|
||||
child: ColoredBox(color: Colors.black.withValues(alpha: s.dim)),
|
||||
|
||||
@@ -2,10 +2,9 @@ import 'dart:convert';
|
||||
import 'package:filesize/filesize.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:jiffy/jiffy.dart';
|
||||
import 'package:localstore/localstore.dart';
|
||||
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
import '../../api/request_cache.dart';
|
||||
import '../../api/cache_store.dart';
|
||||
import '../app_progress_indicator.dart';
|
||||
import 'json_viewer.dart';
|
||||
|
||||
@@ -15,31 +14,11 @@ class CacheView extends StatefulWidget {
|
||||
@override
|
||||
State<CacheView> createState() => _CacheViewState();
|
||||
|
||||
Future<void> clear() async {
|
||||
await Localstore.instance.collection(RequestCache.collection).delete();
|
||||
}
|
||||
|
||||
Future<int> totalSize() async {
|
||||
final data = await Localstore.instance
|
||||
.collection(RequestCache.collection)
|
||||
.get();
|
||||
if (data == null || data.isEmpty) return 0;
|
||||
return data.values.fold<int>(
|
||||
0,
|
||||
(sum, value) => sum + jsonEncode(value).length,
|
||||
) *
|
||||
8;
|
||||
}
|
||||
}
|
||||
|
||||
class _CacheViewState extends State<CacheView> {
|
||||
late Future<Map<String, dynamic>?> files;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
files = Localstore.instance.collection(RequestCache.collection).get();
|
||||
super.initState();
|
||||
}
|
||||
late final Future<Map<String, CacheEntry>> files = CacheStore.instance
|
||||
.readAll();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
@@ -47,24 +26,23 @@ class _CacheViewState extends State<CacheView> {
|
||||
body: FutureBuilder(
|
||||
future: files,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
if (snapshot.hasData && snapshot.data!.isNotEmpty) {
|
||||
return ListView.builder(
|
||||
itemCount: snapshot.data!.length,
|
||||
itemBuilder: (context, index) {
|
||||
final key = snapshot.data!.keys.elementAt(index);
|
||||
final element = snapshot.data![key] as Map<String, dynamic>;
|
||||
final filename = key.split('/').last;
|
||||
final element = snapshot.data![key]!;
|
||||
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.text_snippet_outlined),
|
||||
title: Text(filename),
|
||||
title: Text(key),
|
||||
subtitle: Text(
|
||||
'${filesize(jsonEncode(element).length * 8)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element['lastupdate'] as int).fromNow()}',
|
||||
'${filesize(utf8.encode(element.json).length)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element.lastUpdate).fromNow()}',
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () => JsonViewer.asDialog(
|
||||
context,
|
||||
jsonDecode(element['json'] as String) as Map<String, dynamic>,
|
||||
jsonDecode(element.json) as Map<String, dynamic>,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
+58
-37
@@ -4,6 +4,7 @@ import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
@@ -15,6 +16,7 @@ import '../routing/app_routes.dart';
|
||||
import '../share_intent/remote_file_ref.dart';
|
||||
import '../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../utils/downloads/download_manager.dart';
|
||||
import '../utils/screen_bound_image.dart';
|
||||
import 'app_progress_indicator.dart';
|
||||
import 'async_action_button.dart';
|
||||
import 'centered_leading.dart';
|
||||
@@ -49,6 +51,7 @@ class FileViewer extends StatefulWidget {
|
||||
enum FileViewingActions { openExternal, share, save, sendToChat, saveToCloud }
|
||||
|
||||
class _FileViewerState extends State<FileViewer> {
|
||||
Future<_TextPayload>? _textPayload;
|
||||
final PhotoViewController photoViewController = PhotoViewController();
|
||||
|
||||
late SettingsCubit settings = context.read<SettingsCubit>();
|
||||
@@ -302,7 +305,13 @@ class _FileViewerState extends State<FileViewer> {
|
||||
controller: photoViewController,
|
||||
maxScale: 3.0,
|
||||
minScale: 0.1,
|
||||
imageProvider: Image.file(File(widget.path)).image,
|
||||
// 2× the screen stays sharp while zooming in; the 4096 px cap only
|
||||
// bites on camera-sized photos.
|
||||
imageProvider: screenBoundImage(
|
||||
context,
|
||||
FileImage(File(widget.path)),
|
||||
scale: 2,
|
||||
),
|
||||
backgroundDecoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
@@ -348,13 +357,17 @@ class _FileViewerState extends State<FileViewer> {
|
||||
Widget _buildTextView() => Scaffold(
|
||||
appBar: _appbar(),
|
||||
body: FutureBuilder<_TextPayload>(
|
||||
future: _readTextPayload(),
|
||||
// Cached: a future created in build re-read the file on every rebuild.
|
||||
future: _textPayload ??= compute(
|
||||
_loadTextPayload,
|
||||
(widget.path, _textViewMaxBytes),
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const Center(child: AppProgressIndicator.large());
|
||||
}
|
||||
final payload = snapshot.data!;
|
||||
final lines = const LineSplitter().convert(payload.content);
|
||||
final lines = payload.lines;
|
||||
// Stable gutter width — sized by the highest line number's digit count.
|
||||
final gutterWidth = (lines.length.toString().length * 9.0) + 16;
|
||||
return SelectionArea(
|
||||
@@ -443,38 +456,6 @@ class _FileViewerState extends State<FileViewer> {
|
||||
}
|
||||
|
||||
static const int _textViewMaxBytes = 5 * 1024 * 1024;
|
||||
|
||||
Future<_TextPayload> _readTextPayload() async {
|
||||
final file = File(widget.path);
|
||||
final size = await file.length();
|
||||
final ext = widget.path.split('.').last.toLowerCase();
|
||||
if (size <= _textViewMaxBytes) {
|
||||
final raw = await file.readAsString();
|
||||
return _TextPayload(content: _maybePrettify(raw, ext), truncated: false);
|
||||
}
|
||||
final raf = await file.open();
|
||||
try {
|
||||
final bytes = await raf.read(_textViewMaxBytes);
|
||||
// Truncated payloads stay raw — a parser would choke on the dangling tail.
|
||||
return _TextPayload(
|
||||
content: utf8.decode(bytes, allowMalformed: true),
|
||||
truncated: true,
|
||||
);
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Falls through to the original text on parse errors.
|
||||
String _maybePrettify(String content, String ext) {
|
||||
if (ext != 'json') return content;
|
||||
try {
|
||||
final parsed = jsonDecode(content);
|
||||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||||
} on Object {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionDescriptor {
|
||||
@@ -489,7 +470,47 @@ class _ActionDescriptor {
|
||||
}
|
||||
|
||||
class _TextPayload {
|
||||
final String content;
|
||||
final List<String> lines;
|
||||
final bool truncated;
|
||||
const _TextPayload({required this.content, required this.truncated});
|
||||
const _TextPayload({required this.lines, required this.truncated});
|
||||
}
|
||||
|
||||
/// Reads, prettifies (JSON) and splits a text file on a background isolate:
|
||||
/// up to 5 MB of decoding and line splitting would otherwise freeze the UI.
|
||||
Future<_TextPayload> _loadTextPayload((String, int) args) async {
|
||||
final (path, maxBytes) = args;
|
||||
final file = File(path);
|
||||
final size = await file.length();
|
||||
final ext = path.split('.').last.toLowerCase();
|
||||
if (size <= maxBytes) {
|
||||
final raw = await file.readAsString();
|
||||
return _TextPayload(
|
||||
lines: const LineSplitter().convert(_maybePrettify(raw, ext)),
|
||||
truncated: false,
|
||||
);
|
||||
}
|
||||
final raf = await file.open();
|
||||
try {
|
||||
final bytes = await raf.read(maxBytes);
|
||||
// Truncated payloads stay raw — a parser would choke on the dangling tail.
|
||||
return _TextPayload(
|
||||
lines: const LineSplitter().convert(
|
||||
utf8.decode(bytes, allowMalformed: true),
|
||||
),
|
||||
truncated: true,
|
||||
);
|
||||
} finally {
|
||||
await raf.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Falls through to the original text on parse errors.
|
||||
String _maybePrettify(String content, String ext) {
|
||||
if (ext != 'json') return content;
|
||||
try {
|
||||
final parsed = jsonDecode(content);
|
||||
return const JsonEncoder.withIndent(' ').convert(parsed);
|
||||
} on Object {
|
||||
return content;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,33 @@ class PmImageView extends StatelessWidget {
|
||||
|
||||
const PmImageView({required this.node, super.key});
|
||||
|
||||
/// Full-resolution source, used for the zoomable fullscreen view.
|
||||
static ImageProvider? sourceProvider(PmImage node) {
|
||||
final bytes = node.bytes;
|
||||
if (bytes != null) return MemoryImage(bytes);
|
||||
if (node.src.startsWith('http')) {
|
||||
return CachedNetworkImageProvider(node.src);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Inline images never render wider than the screen, so they are decoded at
|
||||
/// most at its physical width instead of the (often camera-sized) original.
|
||||
/// [PmJsonView] precaches through this too, so both hit the same cache key.
|
||||
static ImageProvider? inlineProvider(BuildContext context, PmImage node) {
|
||||
final source = sourceProvider(node);
|
||||
if (source == null) return null;
|
||||
final width =
|
||||
(MediaQuery.sizeOf(context).width *
|
||||
MediaQuery.devicePixelRatioOf(context))
|
||||
.round();
|
||||
return ResizeImage(source, width: width);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = _imageProvider();
|
||||
final provider = sourceProvider(node);
|
||||
final inline = inlineProvider(context, node);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final available = constraints.maxWidth.isFinite
|
||||
@@ -22,7 +46,13 @@ class PmImageView extends StatelessWidget {
|
||||
|
||||
Widget image = ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: targetWidth ?? available),
|
||||
child: _imageWidget(context, provider),
|
||||
child: inline == null
|
||||
? _brokenImage(context)
|
||||
: Image(
|
||||
image: inline,
|
||||
errorBuilder: (context, error, stack) =>
|
||||
_brokenImage(context),
|
||||
),
|
||||
);
|
||||
|
||||
final href = node.href;
|
||||
@@ -43,30 +73,6 @@ class PmImageView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
ImageProvider? _imageProvider() {
|
||||
if (node.bytes != null) return MemoryImage(node.bytes!);
|
||||
if (node.src.startsWith('http')) {
|
||||
return CachedNetworkImageProvider(node.src);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Widget _imageWidget(BuildContext context, ImageProvider? provider) {
|
||||
if (node.bytes != null) {
|
||||
return Image.memory(
|
||||
node.bytes!,
|
||||
errorBuilder: (context, error, stack) => _brokenImage(context),
|
||||
);
|
||||
}
|
||||
if (node.src.startsWith('http')) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: node.src,
|
||||
errorWidget: (context, url, error) => _brokenImage(context),
|
||||
);
|
||||
}
|
||||
return _brokenImage(context);
|
||||
}
|
||||
|
||||
Widget _brokenImage(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return DecoratedBox(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../app_progress_indicator.dart';
|
||||
import 'pm_document_view.dart';
|
||||
import 'pm_image_view.dart';
|
||||
import 'pm_node.dart';
|
||||
|
||||
/// Renders raw ProseMirror JSON and memoises the parsed tree across rebuilds.
|
||||
@@ -31,7 +31,7 @@ class PmJsonView extends StatefulWidget {
|
||||
class _PmJsonViewState extends State<PmJsonView> {
|
||||
PmNode? _shown;
|
||||
PmNode? _pending;
|
||||
List<ImageProvider> _pendingProviders = const [];
|
||||
List<PmImage> _pendingImages = const [];
|
||||
bool _precacheStarted = false;
|
||||
int _generation = 0;
|
||||
|
||||
@@ -62,17 +62,17 @@ class _PmJsonViewState extends State<PmJsonView> {
|
||||
|
||||
void _parse() {
|
||||
final doc = PmNode.fromJson(widget.json);
|
||||
final providers = <ImageProvider>[];
|
||||
_collectProviders(doc, providers);
|
||||
final images = <PmImage>[];
|
||||
_collectImages(doc, images);
|
||||
_generation++;
|
||||
_precacheStarted = false;
|
||||
if (providers.isEmpty) {
|
||||
if (images.isEmpty) {
|
||||
_shown = doc;
|
||||
_pending = null;
|
||||
_pendingProviders = const [];
|
||||
_pendingImages = const [];
|
||||
} else {
|
||||
_pending = doc;
|
||||
_pendingProviders = providers;
|
||||
_pendingImages = images;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +82,9 @@ class _PmJsonViewState extends State<PmJsonView> {
|
||||
_precacheStarted = true;
|
||||
final generation = _generation;
|
||||
Future.wait([
|
||||
for (final provider in _pendingProviders)
|
||||
precacheImage(provider, context, onError: (_, _) {}),
|
||||
for (final image in _pendingImages)
|
||||
if (PmImageView.inlineProvider(context, image) case final provider?)
|
||||
precacheImage(provider, context, onError: (_, _) {}),
|
||||
])
|
||||
.timeout(PmJsonView.precacheTimeout, onTimeout: () => const [])
|
||||
.whenComplete(() {
|
||||
@@ -91,23 +92,16 @@ class _PmJsonViewState extends State<PmJsonView> {
|
||||
setState(() {
|
||||
_shown = pending;
|
||||
_pending = null;
|
||||
_pendingProviders = const [];
|
||||
_pendingImages = const [];
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _collectProviders(PmNode node, List<ImageProvider> out) {
|
||||
if (node is PmImage) {
|
||||
final bytes = node.bytes;
|
||||
if (bytes != null) {
|
||||
out.add(MemoryImage(bytes));
|
||||
} else if (node.src.startsWith('http')) {
|
||||
out.add(CachedNetworkImageProvider(node.src));
|
||||
}
|
||||
}
|
||||
void _collectImages(PmNode node, List<PmImage> out) {
|
||||
if (node is PmImage) out.add(node);
|
||||
for (final child in node.children) {
|
||||
_collectProviders(child, out);
|
||||
_collectImages(child, out);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ class SharePositionOrigin {
|
||||
static Rect get(BuildContext context) => Rect.fromLTWH(
|
||||
0,
|
||||
0,
|
||||
MediaQuery.of(context).size.width,
|
||||
MediaQuery.of(context).size.height / 2,
|
||||
MediaQuery.sizeOf(context).width,
|
||||
MediaQuery.sizeOf(context).height / 2,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -389,6 +389,10 @@ class _UserAvatarState extends State<UserAvatar> {
|
||||
payload.bytes,
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
// Group avatars arrive at a fixed large server size; decoding them
|
||||
// at display size keeps a chat list from filling the image cache.
|
||||
cacheWidth: (radius * 2 * MediaQuery.devicePixelRatioOf(context))
|
||||
.round(),
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user