5 Commits

Author SHA1 Message Date
558832d188 Update version code 2025-08-10 18:10:59 +08:00
2be88895ed Use image_gallery_saver_plus 2025-08-10 18:09:04 +08:00
1a110e711d update gradle 2025-08-10 17:09:42 +08:00
1ad8329797 Update READMD.md 2025-08-10 15:47:30 +08:00
1cf4da66ad Add auto complete. Close #24 2025-08-10 15:33:48 +08:00
13 changed files with 584 additions and 134 deletions

View File

@@ -1,6 +1,6 @@
# pixes
[![flutter](https://img.shields.io/badge/flutter-3.27.0-blue)](https://flutter.dev/)
[![flutter](https://img.shields.io/badge/flutter-3.32.5-blue)](https://flutter.dev/)
[![License](https://img.shields.io/github/license/wgh136/pixes)](https://github.com/wgh136/pixes/blob/master/LICENSE)
[![Download](https://img.shields.io/github/v/release/wgh136/pixes)](https://github.com/wgh136/pixes)
[![stars](https://img.shields.io/github/stars/wgh136/pixes)](https://github.com/wgh136/pixes/stargazers)

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

View File

@@ -19,7 +19,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.2.1" apply false
id "com.android.application" version "8.9.0" apply false
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
}

View File

@@ -184,7 +184,8 @@
"Emphasize artworks from following artists": "强调关注画师的作品",
"The border of the artworks will be darker": "作品的边框将被加深",
"Initial Page": "初始页面",
"Close the pane to apply the settings": "关闭面板以应用设置"
"Close the pane to apply the settings": "关闭面板以应用设置",
"No results found": "未找到结果"
},
"zh_TW": {
"Search": "搜索",
@@ -371,6 +372,7 @@
"Emphasize artworks from following artists": "強調關注畫師的作品",
"The border of the artworks will be darker": "作品的邊框將被加深",
"Initial Page": "初始頁面",
"Close the pane to apply the settings": "關閉面板以應用設置"
"Close the pane to apply the settings": "關閉面板以應用設置",
"No results found": "未找到結果"
}
}

View File

@@ -0,0 +1,315 @@
import 'package:fluent_ui/fluent_ui.dart';
import 'package:pixes/foundation/app.dart';
class AutoCompleteItem {
final String title;
final String? subtitle;
final VoidCallback onTap;
const AutoCompleteItem({
required this.title,
this.subtitle,
required this.onTap,
});
}
class AutoCompleteData {
final List<AutoCompleteItem> items;
final bool isLoading;
const AutoCompleteData({
this.items = const <AutoCompleteItem>[],
this.isLoading = false,
});
}
class SearchField extends StatefulWidget {
const SearchField({
super.key,
this.autoCompleteItems = const <AutoCompleteItem>[],
this.isLoadingAutoCompleteItems = false,
this.enableAutoComplete = true,
this.textEditingController,
this.placeholder,
this.leading,
this.trailing,
this.foregroundDecoration,
this.onChanged,
this.onSubmitted,
this.padding,
this.focusNode,
this.autoCompleteNoResultsText,
});
final List<AutoCompleteItem> autoCompleteItems;
final bool isLoadingAutoCompleteItems;
final bool enableAutoComplete;
final TextEditingController? textEditingController;
final String? placeholder;
final Widget? leading;
final Widget? trailing;
final WidgetStatePropertyAll<BoxDecoration>? foregroundDecoration;
final void Function(String)? onChanged;
final void Function(String)? onSubmitted;
final EdgeInsets? padding;
final FocusNode? focusNode;
final String? autoCompleteNoResultsText;
@override
State<SearchField> createState() => _SearchFieldState();
}
class _SearchFieldState extends State<SearchField> with TickerProviderStateMixin {
late final ValueNotifier<AutoCompleteData> autoCompleteItems;
late final FocusNode focusNode;
final boxKey = GlobalKey();
OverlayEntry? _overlayEntry;
AnimationController? _animationController;
Animation<double>? _fadeAnimation;
@override
void initState() {
autoCompleteItems = ValueNotifier(AutoCompleteData(
items: widget.autoCompleteItems,
isLoading: widget.isLoadingAutoCompleteItems,
));
focusNode = widget.focusNode ?? FocusNode();
focusNode.addListener(onfocusChange);
super.initState();
}
@override
void dispose() {
_animationController?.dispose();
focusNode.removeListener(onfocusChange);
if (widget.focusNode == null) {
focusNode.dispose();
}
super.dispose();
}
@override
void didUpdateWidget(covariant SearchField oldWidget) {
if (widget.autoCompleteItems != oldWidget.autoCompleteItems ||
widget.isLoadingAutoCompleteItems !=
oldWidget.isLoadingAutoCompleteItems) {
Future.microtask(() {
autoCompleteItems.value = AutoCompleteData(
items: widget.autoCompleteItems,
isLoading: widget.isLoadingAutoCompleteItems,
);
});
}
super.didUpdateWidget(oldWidget);
}
void onfocusChange() {
if (focusNode.hasFocus && widget.enableAutoComplete) {
final box = context.findRenderObject() as RenderBox?;
if (box == null) return;
final overlay = Overlay.of(context);
final position = box.localToGlobal(
Offset.zero,
ancestor: overlay.context.findRenderObject(),
);
if (_overlayEntry != null) {
_removeOverlayWithAnimation();
}
_animationController = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController!,
curve: Curves.easeOut,
));
_overlayEntry = OverlayEntry(
builder: (context) {
return Positioned(
left: position.dx,
width: box.size.width,
top: position.dy + box.size.height,
child: _AnimatedOverlayWrapper(
animation: _fadeAnimation!,
child: _AutoCompleteOverlay(
data: autoCompleteItems,
noResultsText: widget.autoCompleteNoResultsText,
),
),
);
},
);
overlay.insert(_overlayEntry!);
_animationController!.forward();
} else {
_removeOverlayWithAnimation();
}
}
void _removeOverlayWithAnimation() {
if (_overlayEntry != null && _animationController != null) {
_animationController!.reverse().then((_) {
_overlayEntry?.remove();
_overlayEntry = null;
_animationController?.dispose();
_animationController = null;
_fadeAnimation = null;
});
}
}
@override
Widget build(BuildContext context) {
return TextBox(
controller: widget.textEditingController,
key: boxKey,
focusNode: focusNode,
padding: const EdgeInsets.symmetric(horizontal: 12),
placeholder: widget.placeholder,
onChanged: widget.onChanged,
onSubmitted: widget.onSubmitted,
foregroundDecoration: widget.foregroundDecoration,
prefix: widget.leading,
suffix: widget.trailing,
);
}
}
class _AutoCompleteOverlay extends StatefulWidget {
const _AutoCompleteOverlay({required this.data, this.noResultsText});
final ValueNotifier<AutoCompleteData> data;
final String? noResultsText;
@override
State<_AutoCompleteOverlay> createState() => _AutoCompleteOverlayState();
}
class _AutoCompleteOverlayState extends State<_AutoCompleteOverlay> {
late final notifier = widget.data;
var items = <AutoCompleteItem>[];
var isLoading = false;
@override
void initState() {
items = notifier.value.items;
isLoading = notifier.value.isLoading;
notifier.addListener(onItemsChanged);
super.initState();
}
@override
void dispose() {
notifier.removeListener(onItemsChanged);
super.dispose();
}
void onItemsChanged() {
setState(() {
items = notifier.value.items;
isLoading = notifier.value.isLoading;
});
}
@override
Widget build(BuildContext context) {
var items = List<AutoCompleteItem>.from(this.items);
Widget? content;
if (isLoading) {
content = SizedBox(
height: 44,
child: Center(
child: ProgressRing(
activeColor: FluentTheme.of(context).accentColor,
strokeWidth: 2,
).fixWidth(24).fixHeight(24),
),
);
} else if (items.isEmpty) {
content = ListTile(
title: Text(widget.noResultsText ?? 'No results found'),
onPressed: () {},
);
} else {
if (items.length > 8) {
items = items.sublist(0, 8);
}
content = Column(
mainAxisSize: MainAxisSize.min,
children: items.map((item) {
return ListTile(
title: Text(item.title),
subtitle: item.subtitle != null ? Text(item.subtitle!) : null,
onPressed: item.onTap,
);
}).toList(),
);
}
return Card(
backgroundColor: FluentTheme.of(context).micaBackgroundColor,
child: AnimatedSize(
alignment: Alignment.topCenter,
duration: const Duration(milliseconds: 160),
child: content,
),
);
}
}
class _AnimatedOverlayWrapper extends StatelessWidget {
const _AnimatedOverlayWrapper({
required this.animation,
required this.child,
});
final Animation<double> animation;
final Widget child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return FadeTransition(
opacity: animation,
child: Transform.scale(
scale: 0.9 + (0.1 * animation.value),
alignment: Alignment.topCenter,
child: child,
),
);
},
child: child,
);
}
}

View File

@@ -12,7 +12,7 @@ export "state_controller.dart";
export "navigation.dart";
class _App {
final version = "1.1.1";
final version = "1.2.0";
bool get isAndroid => Platform.isAndroid;
bool get isIOS => Platform.isIOS;

View File

@@ -152,6 +152,10 @@ class Tag {
@override
int get hashCode => name.hashCode;
static Tag fromJson(Map<String, dynamic> json) {
return Tag(json['name'] ?? "", json['translated_name']);
}
}
class IllustImage {

View File

@@ -583,4 +583,13 @@ class Network {
return Res.fromErrorRes(res);
}
}
Future<Res<List<Tag>>> getAutoCompleteTags(String keyword) async {
var res = await apiGet("/v2/search/autocomplete?merge_plain_keyword_results=true&word=${Uri.encodeComponent(keyword)}");
if (res.success) {
return Res((res.data["tags"] as List).map((e) => Tag.fromJson(e)).toList());
} else {
return Res.error(res.errorMessage);
}
}
}

View File

@@ -3,7 +3,7 @@ import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
import 'package:image_gallery_saver/image_gallery_saver.dart';
import 'package:image_gallery_saver_plus/image_gallery_saver_plus.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:pixes/components/md.dart';
import 'package:pixes/components/message.dart';
@@ -118,10 +118,11 @@ class _ImagePageState extends State<ImagePage> with WindowListener {
if (!fileName.contains('.')) {
fileName += getExtensionName();
}
await ImageGallerySaver.saveImage(
await ImageGallerySaverPlus.saveImage(
await file.readAsBytes(),
quality: 100,
name: fileName);
name: fileName,
);
if (context.mounted) {
showToast(context, message: "Saved".tl);
}

View File

@@ -4,6 +4,7 @@ import 'package:pixes/appdata.dart';
import 'package:pixes/components/loading.dart';
import 'package:pixes/components/novel.dart';
import 'package:pixes/components/page_route.dart';
import 'package:pixes/components/search_field.dart';
import 'package:pixes/components/user_preview.dart';
import 'package:pixes/foundation/app.dart';
import 'package:pixes/network/network.dart';
@@ -12,6 +13,7 @@ import 'package:pixes/pages/novel_page.dart';
import 'package:pixes/pages/user_info_page.dart';
import 'package:pixes/utils/app_links.dart';
import 'package:pixes/utils/block.dart';
import 'package:pixes/utils/debounce.dart';
import 'package:pixes/utils/ext.dart';
import 'package:pixes/utils/translation.dart';
@@ -21,19 +23,7 @@ import '../components/illust_widget.dart';
import '../components/md.dart';
import '../foundation/image_provider.dart';
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
String text = "";
int searchType = 0;
static const searchTypes = [
const searchTypes = [
"Search artwork",
"Search novel",
"Search user",
@@ -42,7 +32,17 @@ class _SearchPageState extends State<SearchPage> {
"Novel ID"
];
void search() {
class SearchPage extends StatefulWidget {
const SearchPage({super.key});
@override
State<SearchPage> createState() => _SearchPageState();
}
class _SearchPageState extends State<SearchPage> {
int searchType = 0;
void search(String text) {
if (text.isURL && handleLink(Uri.parse(text))) {
return;
} else if ("https://$text".isURL &&
@@ -71,9 +71,19 @@ class _SearchPageState extends State<SearchPage> {
padding: const EdgeInsets.only(top: 8),
content: Column(
children: [
buildSearchBar(),
const SizedBox(
height: 8,
_SearchBar(
searchType: searchType,
onTypeChanged: (type) {
setState(() {
searchType = type;
});
},
onSearch: (text) {
if (text.isEmpty) {
return;
}
search(text);
},
),
const Expanded(
child: _TrendingTagsView(),
@@ -82,102 +92,6 @@ class _SearchPageState extends State<SearchPage> {
),
);
}
final optionController = FlyoutController();
Widget buildSearchBar() {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: SizedBox(
height: 42,
width: double.infinity,
child: LayoutBuilder(
builder: (context, constrains) {
return SizedBox(
height: 42,
width: constrains.maxWidth,
child: Row(
children: [
Expanded(
child: TextBox(
padding: const EdgeInsets.symmetric(horizontal: 12),
placeholder:
'${searchTypes[searchType].tl} / ${"Open link".tl}',
onChanged: (s) => text = s,
onSubmitted: (s) => search(),
foregroundDecoration: WidgetStatePropertyAll(
BoxDecoration(
border: Border.all(
color: ColorScheme.of(context)
.outlineVariant
.toOpacity(0.6)),
borderRadius: BorderRadius.circular(4))),
suffix: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: search,
child: const Icon(
FluentIcons.search,
size: 16,
).paddingHorizontal(12),
),
),
),
),
const SizedBox(
width: 4,
),
FlyoutTarget(
controller: optionController,
child: Button(
child: const SizedBox(
height: 42,
child: Center(
child: Icon(FluentIcons.chevron_down),
),
),
onPressed: () {
optionController.showFlyout(
placementMode: FlyoutPlacementMode.bottomCenter,
builder: buildSearchOption,
barrierColor: Colors.transparent);
},
),
),
const SizedBox(
width: 4,
),
Button(
child: const SizedBox(
height: 42,
child: Center(
child: Icon(FluentIcons.settings),
),
),
onPressed: () {
Navigator.of(context).push(SideBarRoute(SearchSettings(
isNovel: searchType == 1,
)));
},
)
],
),
);
},
),
).paddingHorizontal(16),
);
}
Widget buildSearchOption(BuildContext context) {
return MenuFlyout(
items: List.generate(
searchTypes.length,
(index) => MenuFlyoutItem(
text: Text(searchTypes[index].tl),
onPressed: () => setState(() => searchType = index))),
);
}
}
class _TrendingTagsView extends StatefulWidget {
@@ -803,3 +717,184 @@ class _SearchNovelResultPageState
return res;
}
}
class _SearchBar extends StatefulWidget {
const _SearchBar({
required this.searchType,
required this.onTypeChanged,
required this.onSearch,
});
final int searchType;
final void Function(int) onTypeChanged;
final void Function(String) onSearch;
@override
State<_SearchBar> createState() => _SearchBarState();
}
class _SearchBarState extends State<_SearchBar> {
final optionController = FlyoutController();
final textController = TextEditingController();
var autoCompleteItems = <AutoCompleteItem>[];
var debouncer = Debounce(delay: const Duration(milliseconds: 300));
var autoCompleteKey = 0;
var isLoadingAutoCompleteItems = false;
Widget buildSearchOption(BuildContext context) {
return MenuFlyout(
items: List.generate(
searchTypes.length,
(index) => MenuFlyoutItem(
text: Text(searchTypes[index].tl),
onPressed: () => widget.onTypeChanged(index),
),
),
);
}
void onTextChanged(String text) {
if (widget.searchType == 3 ||
widget.searchType == 4 ||
widget.searchType == 5) {
return;
}
if (text.isEmpty) {
setState(() {
autoCompleteItems = [];
isLoadingAutoCompleteItems = false;
});
return;
}
setState(() {
isLoadingAutoCompleteItems = true;
});
debouncer.call(() async {
var key = ++autoCompleteKey;
var res = await Network().getAutoCompleteTags(text);
if (res.error) {
return;
}
var items = res.data.map((e) {
return AutoCompleteItem(
title: e.name,
subtitle: e.translatedName,
onTap: () {
textController.text = e.name;
widget.onSearch(e.name);
},
);
}).toList();
if (key != autoCompleteKey) {
return; // ignore old request
}
setState(() {
autoCompleteItems = items;
isLoadingAutoCompleteItems = false;
});
});
}
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: SizedBox(
height: 42,
width: double.infinity,
child: LayoutBuilder(
builder: (context, constrains) {
return SizedBox(
height: 42,
width: constrains.maxWidth,
child: Row(
children: [
Expanded(
child: SearchField(
enableAutoComplete: widget.searchType != 3 &&
widget.searchType != 4 &&
widget.searchType != 5,
textEditingController: textController,
autoCompleteNoResultsText: "No results found".tl,
isLoadingAutoCompleteItems: isLoadingAutoCompleteItems,
autoCompleteItems: autoCompleteItems,
padding: const EdgeInsets.symmetric(horizontal: 12),
placeholder:
'${searchTypes[widget.searchType].tl} / ${"Open link".tl}',
onChanged: onTextChanged,
onSubmitted: widget.onSearch,
foregroundDecoration: WidgetStatePropertyAll(
BoxDecoration(
border: Border.all(
color: ColorScheme.of(context)
.outlineVariant
.toOpacity(0.6),
),
borderRadius: BorderRadius.circular(4),
),
),
trailing: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => widget.onSearch(textController.text),
child: const Icon(
FluentIcons.search,
size: 16,
).paddingHorizontal(12),
),
),
),
),
const SizedBox(width: 4),
FlyoutTarget(
controller: optionController,
child: Button(
child: const SizedBox(
height: 42,
child: Center(
child: Icon(FluentIcons.chevron_down),
),
),
onPressed: () {
optionController.showFlyout(
placementMode: FlyoutPlacementMode.bottomCenter,
builder: buildSearchOption,
barrierColor: Colors.transparent,
);
},
),
),
const SizedBox(width: 4),
Button(
child: const SizedBox(
height: 42,
child: Center(
child: Icon(FluentIcons.settings),
),
),
onPressed: () {
Navigator.of(context).push(SideBarRoute(SearchSettings(
isNovel: widget.searchType == 1,
)));
},
)
],
),
);
},
),
).paddingHorizontal(16),
);
}
}

28
lib/utils/debounce.dart Normal file
View File

@@ -0,0 +1,28 @@
import 'dart:async';
import 'dart:ui';
class Debounce {
final Duration delay;
VoidCallback? _action;
Timer? _timer;
Debounce({required this.delay});
void call(VoidCallback action) {
_action = action;
_timer?.cancel();
_timer = Timer(delay, _execute);
}
void _execute() {
if (_action != null) {
_action!();
_action = null;
}
}
void cancel() {
_timer?.cancel();
_action = null;
}
}

View File

@@ -325,15 +325,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image_gallery_saver:
image_gallery_saver_plus:
dependency: "direct main"
description:
path: "."
ref: master
resolved-ref: "38a38c45d3ed229cbc1d827eb2b5aaad1a4519cd"
url: "https://github.com/wgh136/image_gallery_saver"
source: git
version: "2.0.0"
name: image_gallery_saver_plus
sha256: "199b9e24f8d85e98f11e3d35571ab68ae50626ad40e2bb85c84383f69a6950ad"
url: "https://pub.dev"
source: hosted
version: "4.0.1"
intl:
dependency: "direct main"
description:

View File

@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.1.1+111
version: 1.2.0+120
environment:
sdk: '>=3.3.4 <4.0.0'
@@ -60,10 +60,7 @@ dependencies:
webview_flutter: ^4.13.0
flutter_acrylic: 1.0.0+2
device_info_plus: ^11.5.0
image_gallery_saver:
git:
url: https://github.com/wgh136/image_gallery_saver
ref: master
image_gallery_saver_plus: ^4.0.1
dev_dependencies:
flutter_test:
sdk: flutter