Files
venera/lib/components/message.dart
luckyray d874920c88 Feat: Image favorites (#126)
* feat: 增加图片收藏

* feat: 主体图片收藏页面实现

* feat: 点击打开大图浏览

* feat: 数据结构变更

* feat: 基本完成

* feat: 翻译与bug修复

* feat: 实机测试和问题修复

* feat: jm导入, pica历史记录nhentai有问题, 一键反转

* fix: 大小写不一致, 一个htManga, 一个htmanga

* feat: 拉取收藏优化

* feat: 改成以ep为准

* feat: 兜底一些可能报错场景

* chore: 没有用到

* feat: 尽量保证和网络收藏顺序一致

* feat: 支持显示热点tag

* feat: 支持双击收藏, 不过此时禁止放大图片

* fix: 自动塞封面逻辑完善, 切换快速收藏图片立刻生效

* Refactor

* fix updateValue

* feat: 双击功能提示

* fix: 被确定取消收藏的才删除

* Refactor ImageFavoritesPage

* translate author

* feat: 功能提示改到dialog中

* fix text editing

* fix text editing

* feat: 功能提示放到邮件或长按菜单中

* fix: 修复tag过滤不生效问题

* Improve image loading

* The default value of quickCollectImage should be false.

* Refactor DragListener

* Refactor ImageFavoriteItem & ImageFavoritePhotoView

* Refactor

* Fix `ImageFavoriteManager.has`

* Fix UI

* Improve UI

---------

Co-authored-by: nyne <me@nyne.dev>
2025-01-15 16:07:08 +08:00

405 lines
10 KiB
Dart

part of "components.dart";
void showToast({
required String message,
required BuildContext context,
Widget? icon,
Widget? trailing,
int? seconds,
}) {
var newEntry = OverlayEntry(
builder: (context) => _ToastOverlay(
message: message,
icon: icon,
trailing: trailing,
));
var state = context.findAncestorStateOfType<OverlayWidgetState>();
state?.addOverlay(newEntry);
Timer(Duration(seconds: seconds ?? 2), () => state?.remove(newEntry));
}
class _ToastOverlay extends StatelessWidget {
const _ToastOverlay({required this.message, this.icon, this.trailing});
final String message;
final Widget? icon;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return Positioned(
bottom: 24 + MediaQuery.of(context).viewInsets.bottom,
left: 0,
right: 0,
child: Align(
alignment: Alignment.bottomCenter,
child: Material(
color: Theme.of(context).colorScheme.inverseSurface,
borderRadius: BorderRadius.circular(8),
elevation: 2,
textStyle:
ts.withColor(Theme.of(context).colorScheme.onInverseSurface),
child: IconTheme(
data: IconThemeData(
color: Theme.of(context).colorScheme.onInverseSurface),
child: IntrinsicWidth(
child: Container(
padding:
const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
constraints: BoxConstraints(
maxWidth: context.width - 32,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) icon!.paddingRight(8),
Expanded(
child: Text(
message,
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w500),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
if (trailing != null) trailing!.paddingLeft(8)
],
),
),
),
),
),
),
);
}
}
class OverlayWidget extends StatefulWidget {
const OverlayWidget(this.child, {super.key});
final Widget child;
@override
State<OverlayWidget> createState() => OverlayWidgetState();
}
class OverlayWidgetState extends State<OverlayWidget> {
final overlayKey = GlobalKey<OverlayState>();
var entries = <OverlayEntry>[];
void addOverlay(OverlayEntry entry) {
if (overlayKey.currentState != null) {
overlayKey.currentState!.insert(entry);
entries.add(entry);
}
}
void remove(OverlayEntry entry) {
if (entries.remove(entry)) {
entry.remove();
}
}
void removeAll() {
for (var entry in entries) {
entry.remove();
}
entries.clear();
}
@override
Widget build(BuildContext context) {
return Overlay(
key: overlayKey,
initialEntries: [OverlayEntry(builder: (context) => widget.child)],
);
}
}
void showDialogMessage(BuildContext context, String title, String message) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: context.pop,
child: Text("OK".tl),
)
],
),
);
}
Future<void> showConfirmDialog({
required BuildContext context,
required String title,
required String content,
required void Function() onConfirm,
String confirmText = "Confirm",
Color? btnColor,
}) {
return showDialog(
context: context,
builder: (context) => ContentDialog(
title: title,
content: Text(content).paddingHorizontal(16).paddingVertical(8),
actions: [
FilledButton(
onPressed: () {
context.pop();
onConfirm();
},
style: FilledButton.styleFrom(
backgroundColor: btnColor,
),
child: Text(confirmText.tl),
),
],
),
);
}
class LoadingDialogController {
void Function()? closeDialog;
bool closed = false;
void close() {
if (closed) {
return;
}
closed = true;
if (closeDialog == null) {
Future.microtask(closeDialog!);
} else {
closeDialog!();
}
}
}
LoadingDialogController showLoadingDialog(BuildContext context,
{void Function()? onCancel,
bool barrierDismissible = true,
bool allowCancel = true,
String? message,
String cancelButtonText = "Cancel"}) {
var controller = LoadingDialogController();
var loadingDialogRoute = DialogRoute(
context: context,
barrierDismissible: barrierDismissible,
builder: (BuildContext context) {
return Dialog(
child: Container(
width: 100,
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const SizedBox(
width: 30,
height: 30,
child: CircularProgressIndicator(),
),
const SizedBox(
width: 16,
),
Text(
message ?? 'Loading',
style: const TextStyle(fontSize: 16),
),
const Spacer(),
if (allowCancel)
TextButton(
onPressed: () {
controller.close();
onCancel?.call();
},
child: Text(cancelButtonText.tl))
],
),
),
);
});
var navigator = Navigator.of(context, rootNavigator: true);
navigator.push(loadingDialogRoute).then((value) => controller.closed = true);
controller.closeDialog = () {
navigator.removeRoute(loadingDialogRoute);
};
return controller;
}
class ContentDialog extends StatelessWidget {
const ContentDialog({
super.key,
this.title, // 如果不传 title 将不会展示
required this.content,
this.dismissible = true,
this.actions = const [],
});
final String? title;
final Widget content;
final List<Widget> actions;
final bool dismissible;
@override
Widget build(BuildContext context) {
var content = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
title != null
? Appbar(
leading: IconButton(
icon: const Icon(Icons.close),
onPressed: dismissible ? context.pop : null,
),
title: Text(title!),
backgroundColor: Colors.transparent,
)
: const SizedBox.shrink(),
this.content,
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: actions,
).paddingRight(12),
const SizedBox(height: 16),
],
);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: context.brightness == Brightness.dark
? BorderSide(color: context.colorScheme.outlineVariant)
: BorderSide.none,
),
insetPadding: context.width < 400
? const EdgeInsets.symmetric(horizontal: 4)
: const EdgeInsets.symmetric(horizontal: 16),
elevation: 2,
shadowColor: context.colorScheme.shadow,
backgroundColor: context.colorScheme.surface,
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
alignment: Alignment.topCenter,
child: IntrinsicWidth(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: 600,
minWidth: math.min(400, context.width - 16),
),
child: MediaQuery.removePadding(
removeTop: true,
removeBottom: true,
context: context,
child: content,
),
),
),
),
);
}
}
Future<void> showInputDialog({
required BuildContext context,
required String title,
String? hintText,
required FutureOr<Object?> Function(String) onConfirm,
String? initialValue,
String confirmText = "Confirm",
String cancelText = "Cancel",
RegExp? inputValidator,
}) {
var controller = TextEditingController(text: initialValue);
bool isLoading = false;
String? error;
return showDialog(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return ContentDialog(
title: title,
content: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
border: const OutlineInputBorder(),
errorText: error,
),
).paddingHorizontal(12),
actions: [
Button.filled(
isLoading: isLoading,
onPressed: () async {
if (inputValidator != null &&
!inputValidator.hasMatch(controller.text)) {
setState(() => error = "Invalid input");
return;
}
var futureOr = onConfirm(controller.text);
Object? result;
if (futureOr is Future) {
setState(() => isLoading = true);
result = await futureOr;
setState(() => isLoading = false);
} else {
result = futureOr;
}
if (result == null) {
context.pop();
} else {
setState(() => error = result.toString());
}
},
child: Text(confirmText.tl),
),
],
);
},
);
},
);
}
void showInfoDialog({
required BuildContext context,
required String title,
required String content,
String confirmText = "OK",
}) {
showDialog(
context: context,
builder: (context) {
return ContentDialog(
title: title,
content: Text(content).paddingHorizontal(16).paddingVertical(8),
actions: [
Button.filled(
onPressed: context.pop,
child: Text(confirmText.tl),
),
],
);
},
);
}