data-saving: implement gate with bypass

- Implement DataSavingGate util (previous commit was only the shell)
- Update ProfilePictureWidget to always load avatars via UniversalImage
  using fileId, bypassing CloudFileWidget and its data-saving check
- Keep larger media under data-saving control
- Add i18n strings for data-saving mode

Signed-off-by: Texas0295 <kimura@texas0295.top>
This commit is contained in:
Texas0295
2025-09-06 13:53:19 +08:00
parent 07a5a19141
commit a8c3830d67
5 changed files with 96 additions and 21 deletions

View File

@@ -350,6 +350,7 @@
"settingsRealmCompactView": "Compact Realm View", "settingsRealmCompactView": "Compact Realm View",
"settingsMixedFeed": "Mixed Feed", "settingsMixedFeed": "Mixed Feed",
"settingsDataSavingMode": "Data Saving Mode", "settingsDataSavingMode": "Data Saving Mode",
"dataSavingHint": "Data Saving Mode",
"settingsAutoTranslate": "Auto Translate", "settingsAutoTranslate": "Auto Translate",
"settingsHideBottomNav": "Hide Bottom Navigation", "settingsHideBottomNav": "Hide Bottom Navigation",
"settingsSoundEffects": "Sound Effects", "settingsSoundEffects": "Sound Effects",

View File

@@ -316,6 +316,7 @@
"settingsRealmCompactView": "紧凑领域视图", "settingsRealmCompactView": "紧凑领域视图",
"settingsMixedFeed": "混合动态", "settingsMixedFeed": "混合动态",
"settingsDataSavingMode": "流量节省模式", "settingsDataSavingMode": "流量节省模式",
"dataSavingHint": "流量节省模式",
"settingsAutoTranslate": "自动翻译", "settingsAutoTranslate": "自动翻译",
"settingsHideBottomNav": "隐藏底部导航", "settingsHideBottomNav": "隐藏底部导航",
"settingsSoundEffects": "音效", "settingsSoundEffects": "音效",

View File

@@ -316,6 +316,7 @@
"settingsMixedFeed": "混合動態", "settingsMixedFeed": "混合動態",
"settingsAutoTranslate": "自動翻譯", "settingsAutoTranslate": "自動翻譯",
"settingsDataSavingMode": "低數據模式", "settingsDataSavingMode": "低數據模式",
"dataSavingHint": "低數據模式",
"settingsHideBottomNav": "隱藏底部導航", "settingsHideBottomNav": "隱藏底部導航",
"settingsSoundEffects": "音效", "settingsSoundEffects": "音效",
"settingsAprilFoolFeatures": "愚人節功能", "settingsAprilFoolFeatures": "愚人節功能",

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/config.dart';
typedef WidgetBuilder0 = Widget Function();
class DataSavingGate extends ConsumerWidget {
final bool bypass;
final WidgetBuilder0 content;
final Widget placeholder;
const DataSavingGate({
super.key,
required this.bypass,
required this.content,
required this.placeholder,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final dataSaving =
ref.watch(appSettingsNotifierProvider.select((s) => s.dataSavingMode));
if (bypass || !dataSaving) return content();
return placeholder;
}
}

View File

@@ -14,6 +14,7 @@ import 'package:island/widgets/content/audio.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
import 'package:island/utils/data_saving_gate.dart';
import 'image.dart'; import 'image.dart';
import 'video.dart'; import 'video.dart';
@@ -33,6 +34,7 @@ class CloudFileWidget extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final dataSaving = ref.watch(appSettingsNotifierProvider.select((s) => s.dataSavingMode));
final serverUrl = ref.watch(serverUrlProvider); final serverUrl = ref.watch(serverUrlProvider);
final uri = '$serverUrl/drive/files/${item.id}'; final uri = '$serverUrl/drive/files/${item.id}';
@@ -44,7 +46,12 @@ class CloudFileWidget extends HookConsumerWidget {
var content = switch (item.mimeType?.split('/').firstOrNull) { var content = switch (item.mimeType?.split('/').firstOrNull) {
"image" => AspectRatio( "image" => AspectRatio(
aspectRatio: ratio, aspectRatio: ratio,
child: UniversalImage( child: dataSaving ? _DataSavingPlaceholder(
icon: Symbols.image,
onTap: () {
// TODO: single picture unlock logic
})
: UniversalImage(
uri: uri, uri: uri,
blurHash: blurHash:
noBlurhash noBlurhash
@@ -54,7 +61,13 @@ class CloudFileWidget extends HookConsumerWidget {
), ),
"video" => AspectRatio( "video" => AspectRatio(
aspectRatio: ratio, aspectRatio: ratio,
child: CloudVideoWidget(item: item), child: dataSaving ? _DataSavingPlaceholder(
icon: Symbols.play_arrow,
onTap: () {
// TODO: single vedio unlock logic
}
)
: CloudVideoWidget(item: item),
), ),
"audio" => Center( "audio" => Center(
child: ConstrainedBox( child: ConstrainedBox(
@@ -113,6 +126,35 @@ class CloudFileWidget extends HookConsumerWidget {
} }
} }
class _DataSavingPlaceholder extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
const _DataSavingPlaceholder({required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
color: Colors.black26,
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 36,
color: Theme.of(context).colorScheme.onSurfaceVariant),
const Gap(8),
Text(
'dataSavingHint'.tr(),
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
),
),
);
}
}
class CloudVideoWidget extends HookConsumerWidget { class CloudVideoWidget extends HookConsumerWidget {
final SnCloudFile item; final SnCloudFile item;
const CloudVideoWidget({super.key, required this.item}); const CloudVideoWidget({super.key, required this.item});
@@ -314,29 +356,32 @@ class ProfilePictureWidget extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final serverUrl = ref.watch(serverUrlProvider); final serverUrl = ref.watch(serverUrlProvider);
final uri = '$serverUrl/drive/files/${file?.id ?? fileId}'; final String? id = file?.id ?? fileId;
final fallback = Icon(
fallbackIcon ?? Symbols.account_circle,
size: radius,
color: fallbackColor ?? Theme.of(context).colorScheme.onPrimaryContainer,
).center();
return ClipRRect( return ClipRRect(
borderRadius: borderRadius: borderRadius == null
borderRadius == null
? BorderRadius.all(Radius.circular(radius)) ? BorderRadius.all(Radius.circular(radius))
: BorderRadius.all(Radius.circular(borderRadius!)), : BorderRadius.all(Radius.circular(borderRadius!)),
child: Container( child: Container(
width: radius * 2, width: radius * 2,
height: radius * 2, height: radius * 2,
color: Theme.of(context).colorScheme.primaryContainer, color: Theme.of(context).colorScheme.primaryContainer,
child: child: id == null
file != null ? fallback
? CloudFileWidget(item: file!, fit: BoxFit.cover) : DataSavingGate(
: fileId == null bypass: true, // 小頭像永遠繞過低數據
? Icon( placeholder: fallback,
fallbackIcon ?? Symbols.account_circle, content: () => UniversalImage(
size: radius, uri: '$serverUrl/drive/files/$id',
color: fit: BoxFit.cover,
fallbackColor ?? ),
Theme.of(context).colorScheme.onPrimaryContainer, ),
).center()
: UniversalImage(uri: uri, fit: BoxFit.cover),
), ),
); );
} }