mirror of
https://github.com/wgh136/pixes.git
synced 2025-09-27 12:57:24 +00:00
Initial commit
This commit is contained in:
23
lib/appdata.dart
Normal file
23
lib/appdata.dart
Normal file
@@ -0,0 +1,23 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'foundation/app.dart';
|
||||
import 'network/models.dart';
|
||||
|
||||
class _Appdata {
|
||||
Account? account;
|
||||
|
||||
void writeData() async {
|
||||
await File("${App.dataPath}/account.json")
|
||||
.writeAsString(jsonEncode(account));
|
||||
}
|
||||
|
||||
Future<void> readData() async {
|
||||
final file = File("${App.dataPath}/account.json");
|
||||
if (file.existsSync()) {
|
||||
account = Account.fromJson(jsonDecode(await file.readAsString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final appdata = _Appdata();
|
321
lib/components/animated_image.dart
Normal file
321
lib/components/animated_image.dart
Normal file
@@ -0,0 +1,321 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
class AnimatedImage extends StatefulWidget {
|
||||
/// show animation when loading is complete.
|
||||
AnimatedImage({
|
||||
required ImageProvider image,
|
||||
super.key,
|
||||
double scale = 1.0,
|
||||
this.semanticLabel,
|
||||
this.excludeFromSemantics = false,
|
||||
this.width,
|
||||
this.height,
|
||||
this.color,
|
||||
this.opacity,
|
||||
this.colorBlendMode,
|
||||
this.fit,
|
||||
this.alignment = Alignment.center,
|
||||
this.repeat = ImageRepeat.noRepeat,
|
||||
this.centerSlice,
|
||||
this.matchTextDirection = false,
|
||||
this.gaplessPlayback = false,
|
||||
this.filterQuality = FilterQuality.low,
|
||||
this.isAntiAlias = false,
|
||||
Map<String, String>? headers,
|
||||
int? cacheWidth,
|
||||
int? cacheHeight,
|
||||
}
|
||||
): image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
|
||||
assert(cacheWidth == null || cacheWidth > 0),
|
||||
assert(cacheHeight == null || cacheHeight > 0);
|
||||
|
||||
final ImageProvider image;
|
||||
|
||||
final String? semanticLabel;
|
||||
|
||||
final bool excludeFromSemantics;
|
||||
|
||||
final double? width;
|
||||
|
||||
final double? height;
|
||||
|
||||
final bool gaplessPlayback;
|
||||
|
||||
final bool matchTextDirection;
|
||||
|
||||
final Rect? centerSlice;
|
||||
|
||||
final ImageRepeat repeat;
|
||||
|
||||
final AlignmentGeometry alignment;
|
||||
|
||||
final BoxFit? fit;
|
||||
|
||||
final BlendMode? colorBlendMode;
|
||||
|
||||
final FilterQuality filterQuality;
|
||||
|
||||
final Animation<double>? opacity;
|
||||
|
||||
final Color? color;
|
||||
|
||||
final bool isAntiAlias;
|
||||
|
||||
static void clear() => _AnimatedImageState.clear();
|
||||
|
||||
@override
|
||||
State<AnimatedImage> createState() => _AnimatedImageState();
|
||||
}
|
||||
|
||||
class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserver {
|
||||
ImageStream? _imageStream;
|
||||
ImageInfo? _imageInfo;
|
||||
ImageChunkEvent? _loadingProgress;
|
||||
bool _isListeningToStream = false;
|
||||
late bool _invertColors;
|
||||
int? _frameNumber;
|
||||
bool _wasSynchronouslyLoaded = false;
|
||||
late DisposableBuildContext<State<AnimatedImage>> _scrollAwareContext;
|
||||
Object? _lastException;
|
||||
ImageStreamCompleterHandle? _completerHandle;
|
||||
|
||||
static final Map<int, Size> _cache = {};
|
||||
|
||||
static clear() => _cache.clear();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_scrollAwareContext = DisposableBuildContext<State<AnimatedImage>>(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
assert(_imageStream != null);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_stopListeningToStream();
|
||||
_completerHandle?.dispose();
|
||||
_scrollAwareContext.dispose();
|
||||
_replaceImage(info: null);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_updateInvertColors();
|
||||
_resolveImage();
|
||||
|
||||
if (TickerMode.of(context)) {
|
||||
_listenToStream();
|
||||
} else {
|
||||
_stopListeningToStream(keepStreamAlive: true);
|
||||
}
|
||||
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.image != oldWidget.image) {
|
||||
_resolveImage();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAccessibilityFeatures() {
|
||||
super.didChangeAccessibilityFeatures();
|
||||
setState(() {
|
||||
_updateInvertColors();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void reassemble() {
|
||||
_resolveImage(); // in case the image cache was flushed
|
||||
super.reassemble();
|
||||
}
|
||||
|
||||
void _updateInvertColors() {
|
||||
_invertColors = MediaQuery.maybeInvertColorsOf(context)
|
||||
?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
|
||||
}
|
||||
|
||||
void _resolveImage() {
|
||||
final ScrollAwareImageProvider provider = ScrollAwareImageProvider<Object>(
|
||||
context: _scrollAwareContext,
|
||||
imageProvider: widget.image,
|
||||
);
|
||||
final ImageStream newStream =
|
||||
provider.resolve(createLocalImageConfiguration(
|
||||
context,
|
||||
size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null,
|
||||
));
|
||||
_updateSourceStream(newStream);
|
||||
}
|
||||
|
||||
ImageStreamListener? _imageStreamListener;
|
||||
ImageStreamListener _getListener({bool recreateListener = false}) {
|
||||
if(_imageStreamListener == null || recreateListener) {
|
||||
_lastException = null;
|
||||
_imageStreamListener = ImageStreamListener(
|
||||
_handleImageFrame,
|
||||
onChunk: _handleImageChunk,
|
||||
onError: (Object error, StackTrace? stackTrace) {
|
||||
setState(() {
|
||||
_lastException = error;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
return _imageStreamListener!;
|
||||
}
|
||||
|
||||
void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
|
||||
setState(() {
|
||||
_replaceImage(info: imageInfo);
|
||||
_loadingProgress = null;
|
||||
_lastException = null;
|
||||
_frameNumber = _frameNumber == null ? 0 : _frameNumber! + 1;
|
||||
_wasSynchronouslyLoaded = _wasSynchronouslyLoaded | synchronousCall;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleImageChunk(ImageChunkEvent event) {
|
||||
setState(() {
|
||||
_loadingProgress = event;
|
||||
_lastException = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _replaceImage({required ImageInfo? info}) {
|
||||
final ImageInfo? oldImageInfo = _imageInfo;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) => oldImageInfo?.dispose());
|
||||
_imageInfo = info;
|
||||
}
|
||||
|
||||
// Updates _imageStream to newStream, and moves the stream listener
|
||||
// registration from the old stream to the new stream (if a listener was
|
||||
// registered).
|
||||
void _updateSourceStream(ImageStream newStream) {
|
||||
if (_imageStream?.key == newStream.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isListeningToStream) {
|
||||
_imageStream!.removeListener(_getListener());
|
||||
}
|
||||
|
||||
if (!widget.gaplessPlayback) {
|
||||
setState(() { _replaceImage(info: null); });
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loadingProgress = null;
|
||||
_frameNumber = null;
|
||||
_wasSynchronouslyLoaded = false;
|
||||
});
|
||||
|
||||
_imageStream = newStream;
|
||||
if (_isListeningToStream) {
|
||||
_imageStream!.addListener(_getListener());
|
||||
}
|
||||
}
|
||||
|
||||
void _listenToStream() {
|
||||
if (_isListeningToStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
_imageStream!.addListener(_getListener());
|
||||
_completerHandle?.dispose();
|
||||
_completerHandle = null;
|
||||
|
||||
_isListeningToStream = true;
|
||||
}
|
||||
|
||||
/// Stops listening to the image stream, if this state object has attached a
|
||||
/// listener.
|
||||
///
|
||||
/// If the listener from this state is the last listener on the stream, the
|
||||
/// stream will be disposed. To keep the stream alive, set `keepStreamAlive`
|
||||
/// to true, which create [ImageStreamCompleterHandle] to keep the completer
|
||||
/// alive and is compatible with the [TickerMode] being off.
|
||||
void _stopListeningToStream({bool keepStreamAlive = false}) {
|
||||
if (!_isListeningToStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keepStreamAlive && _completerHandle == null && _imageStream?.completer != null) {
|
||||
_completerHandle = _imageStream!.completer!.keepAlive();
|
||||
}
|
||||
|
||||
_imageStream!.removeListener(_getListener());
|
||||
_isListeningToStream = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget result;
|
||||
|
||||
if(_imageInfo != null){
|
||||
// build image
|
||||
result = RawImage(
|
||||
// Do not clone the image, because RawImage is a stateless wrapper.
|
||||
// The image will be disposed by this state object when it is not needed
|
||||
// anymore, such as when it is unmounted or when the image stream pushes
|
||||
// a new image.
|
||||
image: _imageInfo?.image,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
debugImageLabel: _imageInfo?.debugLabel,
|
||||
scale: _imageInfo?.scale ?? 1.0,
|
||||
color: widget.color,
|
||||
opacity: widget.opacity,
|
||||
colorBlendMode: widget.colorBlendMode,
|
||||
alignment: widget.alignment,
|
||||
repeat: widget.repeat,
|
||||
centerSlice: widget.centerSlice,
|
||||
matchTextDirection: widget.matchTextDirection,
|
||||
invertColors: _invertColors,
|
||||
isAntiAlias: widget.isAntiAlias,
|
||||
filterQuality: widget.filterQuality,
|
||||
fit: widget.fit,
|
||||
);
|
||||
} else if (_lastException != null) {
|
||||
result = const Center(
|
||||
child: Icon(FluentIcons.error),
|
||||
);
|
||||
|
||||
if (!widget.excludeFromSemantics) {
|
||||
result = Semantics(
|
||||
container: widget.semanticLabel != null,
|
||||
image: true,
|
||||
label: widget.semanticLabel ?? '',
|
||||
child: result,
|
||||
);
|
||||
}
|
||||
} else{
|
||||
result = const Center();
|
||||
}
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
reverseDuration: const Duration(milliseconds: 200),
|
||||
child: result,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder description) {
|
||||
super.debugFillProperties(description);
|
||||
description.add(DiagnosticsProperty<ImageStream>('stream', _imageStream));
|
||||
description.add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo));
|
||||
description.add(DiagnosticsProperty<ImageChunkEvent>('loadingProgress', _loadingProgress));
|
||||
description.add(DiagnosticsProperty<int>('frameNumber', _frameNumber));
|
||||
description.add(DiagnosticsProperty<bool>('wasSynchronouslyLoaded', _wasSynchronouslyLoaded));
|
||||
}
|
||||
}
|
38
lib/components/color_scheme.dart
Normal file
38
lib/components/color_scheme.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ColorScheme extends InheritedWidget{
|
||||
final Brightness brightness;
|
||||
|
||||
const ColorScheme({super.key, required this.brightness, required super.child});
|
||||
|
||||
static ColorScheme of(BuildContext context){
|
||||
return context.dependOnInheritedWidgetOfExactType<ColorScheme>()!;
|
||||
}
|
||||
|
||||
bool get _light => brightness == Brightness.light;
|
||||
|
||||
Color get primary => _light ? const Color(0xff00538a) : const Color(0xff9ccaff);
|
||||
|
||||
Color get primaryContainer => _light ? const Color(0xff5fbdff) : const Color(0xff0079c5);
|
||||
|
||||
Color get secondary => _light ? const Color(0xff426182) : const Color(0xffaac9ef);
|
||||
|
||||
Color get secondaryContainer => _light ? const Color(0xffc1dcff) : const Color(0xff1f3f5f);
|
||||
|
||||
Color get tertiary => _light ? const Color(0xff743192) : const Color(0xffebb2ff);
|
||||
|
||||
Color get tertiaryContainer => _light ? const Color(0xffcf9ae8) : const Color(0xff9c58ba);
|
||||
|
||||
Color get outline => _light ? const Color(0xff707883) : const Color(0xff89919d);
|
||||
|
||||
Color get outlineVariant => _light ? const Color(0xffbfc7d3) : const Color(0xff404752);
|
||||
|
||||
Color get errorColor => _light ? const Color(0xffff3131) : const Color(0xfff86a6a);
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(covariant InheritedWidget oldWidget) {
|
||||
return oldWidget is!ColorScheme || brightness != oldWidget.brightness;
|
||||
}
|
||||
}
|
47
lib/components/illust_widget.dart
Normal file
47
lib/components/illust_widget.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:pixes/components/animated_image.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
import 'package:pixes/foundation/image_provider.dart';
|
||||
import 'package:pixes/network/models.dart';
|
||||
|
||||
import '../pages/illust_page.dart';
|
||||
|
||||
class IllustWidget extends StatelessWidget {
|
||||
const IllustWidget(this.illust, {super.key});
|
||||
|
||||
final Illust illust;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, constrains) {
|
||||
final width = constrains.maxWidth;
|
||||
final height = illust.height * width / illust.width;
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
|
||||
child: Card(
|
||||
padding: EdgeInsets.zero,
|
||||
margin: EdgeInsets.zero,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: (){
|
||||
context.to(() => IllustPage(illust));
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(illust.images.first.medium),
|
||||
fit: BoxFit.cover,
|
||||
width: width-16.0,
|
||||
height: height-16.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
106
lib/components/loading.dart
Normal file
106
lib/components/loading.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
import 'package:pixes/network/res.dart';
|
||||
|
||||
abstract class LoadingState<T extends StatefulWidget, S extends Object> extends State<T>{
|
||||
bool isLoading = true;
|
||||
|
||||
S? data;
|
||||
|
||||
String? error;
|
||||
|
||||
Future<Res<S>> loadData();
|
||||
|
||||
Widget buildContent(BuildContext context, S data);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if(isLoading){
|
||||
loadData().then((value) {
|
||||
if(value.success) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
data = value.data;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
error = value.errorMessage!;
|
||||
});
|
||||
}
|
||||
});
|
||||
return const Center(
|
||||
child: ProgressRing(),
|
||||
);
|
||||
} else if (error != null){
|
||||
return Center(
|
||||
child: Text(error!),
|
||||
);
|
||||
} else {
|
||||
return buildContent(context, data!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object> extends State<T>{
|
||||
bool _isFirstLoading = true;
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
List<S>? _data;
|
||||
|
||||
String? _error;
|
||||
|
||||
int _page = 1;
|
||||
|
||||
Future<Res<List<S>>> loadData(int page);
|
||||
|
||||
Widget buildContent(BuildContext context, final List<S> data);
|
||||
|
||||
bool get isLoading => _isLoading || _isFirstLoading;
|
||||
|
||||
void nextPage() {
|
||||
if(_isLoading) return;
|
||||
_isLoading = true;
|
||||
loadData(_page).then((value) {
|
||||
_isLoading = false;
|
||||
if(value.success) {
|
||||
_page++;
|
||||
setState(() {
|
||||
_data!.addAll(value.data);
|
||||
});
|
||||
} else {
|
||||
context.showToast(message: "Network Error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if(_isFirstLoading){
|
||||
loadData(_page).then((value) {
|
||||
if(value.success) {
|
||||
_page++;
|
||||
setState(() {
|
||||
_isFirstLoading = false;
|
||||
_data = value.data;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isFirstLoading = false;
|
||||
_error = value.errorMessage!;
|
||||
});
|
||||
}
|
||||
});
|
||||
return const Center(
|
||||
child: ProgressRing(),
|
||||
);
|
||||
} else if (_error != null){
|
||||
return Center(
|
||||
child: Text(_error!),
|
||||
);
|
||||
} else {
|
||||
return buildContent(context, _data!);
|
||||
}
|
||||
}
|
||||
}
|
3
lib/components/md.dart
Normal file
3
lib/components/md.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef MdIcons = Icons;
|
103
lib/components/message.dart
Normal file
103
lib/components/message.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
|
||||
void showToast(BuildContext context, {required String message, IconData? icon}) {
|
||||
var newEntry = OverlayEntry(
|
||||
builder: (context) => ToastOverlay(message: message, icon: icon));
|
||||
|
||||
OverlayWidget.of(context)?.addOverlay(newEntry);
|
||||
|
||||
Timer(const Duration(seconds: 2), () => OverlayWidget.of(context)?.remove(newEntry));
|
||||
}
|
||||
|
||||
class ToastOverlay extends StatelessWidget {
|
||||
const ToastOverlay({required this.message, this.icon, super.key});
|
||||
|
||||
final String message;
|
||||
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
bottom: 24 + MediaQuery.of(context).viewInsets.bottom,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: PhysicalModel(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
elevation: 1,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) Icon(icon),
|
||||
if (icon != null)
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w500),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OverlayWidget extends StatefulWidget {
|
||||
const OverlayWidget(this.child, {super.key});
|
||||
|
||||
final Widget child;
|
||||
|
||||
static OverlayWidgetState? of(BuildContext context) {
|
||||
return LookupBoundary.findAncestorStateOfType<OverlayWidgetState>(context);
|
||||
}
|
||||
|
||||
@override
|
||||
State<OverlayWidget> createState() => OverlayWidgetState();
|
||||
}
|
||||
|
||||
class OverlayWidgetState extends State<OverlayWidget> {
|
||||
var 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)],
|
||||
);
|
||||
}
|
||||
}
|
300
lib/components/page_route.dart
Normal file
300
lib/components/page_route.dart
Normal file
@@ -0,0 +1,300 @@
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
|
||||
const double _kBackGestureWidth = 20.0;
|
||||
const int _kMaxDroppedSwipePageForwardAnimationTime = 800;
|
||||
const int _kMaxPageBackAnimationTime = 300;
|
||||
const double _kMinFlingVelocity = 1.0;
|
||||
|
||||
class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin {
|
||||
/// Construct a MaterialPageRoute whose contents are defined by [builder].
|
||||
AppPageRoute({
|
||||
required this.builder,
|
||||
super.settings,
|
||||
this.maintainState = true,
|
||||
super.fullscreenDialog,
|
||||
super.allowSnapshotting = true,
|
||||
super.barrierDismissible = false,
|
||||
this.enableIOSGesture = true,
|
||||
this.preventRebuild = true,
|
||||
}) {
|
||||
assert(opaque);
|
||||
}
|
||||
|
||||
/// Builds the primary contents of the route.
|
||||
final WidgetBuilder builder;
|
||||
|
||||
@override
|
||||
Widget buildContent(BuildContext context) {
|
||||
return builder(context);
|
||||
}
|
||||
|
||||
@override
|
||||
final bool maintainState;
|
||||
|
||||
@override
|
||||
String get debugLabel => '${super.debugLabel}(${settings.name})';
|
||||
|
||||
@override
|
||||
final bool enableIOSGesture;
|
||||
|
||||
@override
|
||||
final bool preventRebuild;
|
||||
|
||||
static void updateBackButton() {
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
StateController.findOrNull(tag: "back_button")?.update();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
|
||||
/// Builds the primary contents of the route.
|
||||
@protected
|
||||
Widget buildContent(BuildContext context);
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 300);
|
||||
|
||||
@override
|
||||
Color? get barrierColor => null;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => null;
|
||||
|
||||
@override
|
||||
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
|
||||
// Don't perform outgoing animation if the next route is a fullscreen dialog.
|
||||
return nextRoute is PageRoute && !nextRoute.fullscreenDialog;
|
||||
}
|
||||
|
||||
bool get enableIOSGesture;
|
||||
|
||||
bool get preventRebuild;
|
||||
|
||||
Widget? _child;
|
||||
|
||||
@override
|
||||
Widget buildPage(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
Widget result;
|
||||
|
||||
if (preventRebuild) {
|
||||
result = _child ?? (_child = buildContent(context));
|
||||
} else {
|
||||
result = buildContent(context);
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
scopesRoute: true,
|
||||
explicitChildNodes: true,
|
||||
child: result,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _isPopGestureEnabled<T>(PageRoute<T> route) {
|
||||
if (route.isFirst ||
|
||||
route.willHandlePopInternally ||
|
||||
route.popDisposition == RoutePopDisposition.doNotPop ||
|
||||
route.fullscreenDialog ||
|
||||
route.animation!.status != AnimationStatus.completed ||
|
||||
route.secondaryAnimation!.status != AnimationStatus.dismissed ||
|
||||
route.navigator!.userGestureInProgress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildTransitions(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation, Widget child) {
|
||||
return DrillInPageTransition(
|
||||
animation: CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: FluentTheme.of(context).animationCurve,
|
||||
),
|
||||
child: enableIOSGesture
|
||||
? IOSBackGestureDetector(
|
||||
gestureWidth: _kBackGestureWidth,
|
||||
enabledCallback: () => _isPopGestureEnabled<T>(this),
|
||||
onStartPopGesture: () => _startPopGesture(this),
|
||||
child: child)
|
||||
: child,
|
||||
);
|
||||
}
|
||||
|
||||
IOSBackGestureController _startPopGesture(PageRoute<T> route) {
|
||||
return IOSBackGestureController(route.controller!, route.navigator!);
|
||||
}
|
||||
}
|
||||
|
||||
class IOSBackGestureController {
|
||||
final AnimationController controller;
|
||||
|
||||
final NavigatorState navigator;
|
||||
|
||||
IOSBackGestureController(this.controller, this.navigator) {
|
||||
navigator.didStartUserGesture();
|
||||
}
|
||||
|
||||
void dragEnd(double velocity) {
|
||||
const Curve animationCurve = Curves.fastLinearToSlowEaseIn;
|
||||
final bool animateForward;
|
||||
|
||||
if (velocity.abs() >= _kMinFlingVelocity) {
|
||||
animateForward = velocity <= 0;
|
||||
} else {
|
||||
animateForward = controller.value > 0.5;
|
||||
}
|
||||
|
||||
if (animateForward) {
|
||||
final droppedPageForwardAnimationTime = min(
|
||||
lerpDouble(
|
||||
_kMaxDroppedSwipePageForwardAnimationTime, 0, controller.value)!
|
||||
.floor(),
|
||||
_kMaxPageBackAnimationTime,
|
||||
);
|
||||
controller.animateTo(1.0,
|
||||
duration: Duration(milliseconds: droppedPageForwardAnimationTime),
|
||||
curve: animationCurve);
|
||||
} else {
|
||||
navigator.pop();
|
||||
if (controller.isAnimating) {
|
||||
final droppedPageBackAnimationTime = lerpDouble(
|
||||
0, _kMaxDroppedSwipePageForwardAnimationTime, controller.value)!
|
||||
.floor();
|
||||
controller.animateBack(0.0,
|
||||
duration: Duration(milliseconds: droppedPageBackAnimationTime),
|
||||
curve: animationCurve);
|
||||
}
|
||||
}
|
||||
|
||||
if (controller.isAnimating) {
|
||||
late AnimationStatusListener animationStatusCallback;
|
||||
animationStatusCallback = (status) {
|
||||
navigator.didStopUserGesture();
|
||||
controller.removeStatusListener(animationStatusCallback);
|
||||
};
|
||||
controller.addStatusListener(animationStatusCallback);
|
||||
} else {
|
||||
navigator.didStopUserGesture();
|
||||
}
|
||||
}
|
||||
|
||||
void dragUpdate(double delta) {
|
||||
controller.value -= delta;
|
||||
}
|
||||
}
|
||||
|
||||
class IOSBackGestureDetector extends StatefulWidget {
|
||||
const IOSBackGestureDetector(
|
||||
{required this.enabledCallback,
|
||||
required this.child,
|
||||
required this.gestureWidth,
|
||||
required this.onStartPopGesture,
|
||||
super.key});
|
||||
|
||||
final double gestureWidth;
|
||||
|
||||
final bool Function() enabledCallback;
|
||||
|
||||
final IOSBackGestureController Function() onStartPopGesture;
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<IOSBackGestureDetector> createState() => _IOSBackGestureDetectorState();
|
||||
}
|
||||
|
||||
class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
|
||||
IOSBackGestureController? _backGestureController;
|
||||
|
||||
late HorizontalDragGestureRecognizer _recognizer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_recognizer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_recognizer = HorizontalDragGestureRecognizer(debugOwner: this)
|
||||
..onStart = _handleDragStart
|
||||
..onUpdate = _handleDragUpdate
|
||||
..onEnd = _handleDragEnd
|
||||
..onCancel = _handleDragCancel;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var dragAreaWidth = Directionality.of(context) == TextDirection.ltr
|
||||
? MediaQuery.of(context).padding.left
|
||||
: MediaQuery.of(context).padding.right;
|
||||
dragAreaWidth = max(dragAreaWidth, widget.gestureWidth);
|
||||
return Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: <Widget>[
|
||||
widget.child,
|
||||
Positioned(
|
||||
width: dragAreaWidth,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0,
|
||||
child: Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
if (widget.enabledCallback()) _recognizer.addPointer(event);
|
||||
}
|
||||
|
||||
void _handleDragCancel() {
|
||||
assert(mounted);
|
||||
_backGestureController?.dragEnd(0.0);
|
||||
_backGestureController = null;
|
||||
}
|
||||
|
||||
double _convertToLogical(double value) {
|
||||
switch (Directionality.of(context)) {
|
||||
case TextDirection.rtl:
|
||||
return -value;
|
||||
case TextDirection.ltr:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController != null);
|
||||
_backGestureController!.dragEnd(_convertToLogical(
|
||||
details.velocity.pixelsPerSecond.dx / context.size!.width));
|
||||
_backGestureController = null;
|
||||
}
|
||||
|
||||
void _handleDragStart(DragStartDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController == null);
|
||||
_backGestureController = widget.onStartPopGesture();
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController != null);
|
||||
_backGestureController!.dragUpdate(
|
||||
_convertToLogical(details.primaryDelta! / context.size!.width));
|
||||
}
|
||||
}
|
72
lib/components/segmented_button.dart
Normal file
72
lib/components/segmented_button.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
|
||||
import 'color_scheme.dart';
|
||||
|
||||
class SegmentedButton<T> extends StatelessWidget {
|
||||
const SegmentedButton(
|
||||
{required this.options,
|
||||
required this.value,
|
||||
required this.onPressed,
|
||||
super.key});
|
||||
|
||||
final List<SegmentedButtonOption<T>> options;
|
||||
|
||||
final T value;
|
||||
|
||||
final void Function(T key) onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Card(
|
||||
padding: EdgeInsets.zero,
|
||||
child: SizedBox(
|
||||
height: 36,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: options.map((e) => buildButton(e)).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildButton(SegmentedButtonOption<T> e) {
|
||||
bool active = value == e.key;
|
||||
return HoverButton(
|
||||
cursor: active ? MouseCursor.defer : SystemMouseCursors.click,
|
||||
onPressed: () => onPressed(e.key),
|
||||
builder: (context, states) {
|
||||
var textColor = active ? null : ColorScheme.of(context).outline;
|
||||
var backgroundColor = active ? null : ButtonState.resolveWith((states) {
|
||||
return ButtonThemeData.buttonColor(context, states);
|
||||
}).resolve(states);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: e != options.last
|
||||
? Border(
|
||||
right: BorderSide(
|
||||
width: 0.6,
|
||||
color: ColorScheme.of(context).outlineVariant))
|
||||
: null),
|
||||
child: Center(
|
||||
child: Text(e.text,
|
||||
style: TextStyle(
|
||||
color: textColor, fontWeight: FontWeight.w500))
|
||||
.paddingHorizontal(12),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class SegmentedButtonOption<T> {
|
||||
final T key;
|
||||
final String text;
|
||||
|
||||
const SegmentedButtonOption(this.key, this.text);
|
||||
}
|
41
lib/foundation/app.dart
Normal file
41
lib/foundation/app.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
export "widget_utils.dart";
|
||||
export "state_controller.dart";
|
||||
export "navigation.dart";
|
||||
|
||||
class _App {
|
||||
bool get isAndroid => Platform.isAndroid;
|
||||
bool get isIOS => Platform.isIOS;
|
||||
bool get isWindows => Platform.isWindows;
|
||||
bool get isLinux => Platform.isLinux;
|
||||
bool get isMacOS => Platform.isMacOS;
|
||||
bool get isDesktop =>
|
||||
Platform.isWindows || Platform.isLinux || Platform.isMacOS;
|
||||
bool get isMobile => Platform.isAndroid || Platform.isIOS;
|
||||
|
||||
Locale get locale {
|
||||
Locale deviceLocale = PlatformDispatcher.instance.locale;
|
||||
if (deviceLocale.languageCode == "zh" && deviceLocale.scriptCode == "Hant") {
|
||||
deviceLocale = const Locale("zh", "TW");
|
||||
}
|
||||
return deviceLocale;
|
||||
}
|
||||
|
||||
late String dataPath;
|
||||
late String cachePath;
|
||||
|
||||
init() async{
|
||||
cachePath = (await getApplicationCacheDirectory()).path;
|
||||
dataPath = (await getApplicationSupportDirectory()).path;
|
||||
}
|
||||
|
||||
final rootNavigatorKey = GlobalKey<NavigatorState>();
|
||||
}
|
||||
|
||||
// ignore: non_constant_identifier_names
|
||||
final App = _App();
|
255
lib/foundation/cache_manager.dart
Normal file
255
lib/foundation/cache_manager.dart
Normal file
@@ -0,0 +1,255 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:pixes/utils/io.dart';
|
||||
import 'package:sqlite3/sqlite3.dart';
|
||||
|
||||
import 'app.dart';
|
||||
|
||||
class CacheManager {
|
||||
static String get cachePath => '${App.cachePath}/cache';
|
||||
|
||||
static CacheManager? instance;
|
||||
|
||||
late Database _db;
|
||||
|
||||
int? _currentSize;
|
||||
|
||||
/// size in bytes
|
||||
int get currentSize => _currentSize ?? 0;
|
||||
|
||||
int dir = 0;
|
||||
|
||||
int _limitSize = 2 * 1024 * 1024 * 1024;
|
||||
|
||||
CacheManager._create(){
|
||||
Directory(cachePath).createSync(recursive: true);
|
||||
_db = sqlite3.open('${App.dataPath}/cache.db');
|
||||
_db.execute('''
|
||||
CREATE TABLE IF NOT EXISTS cache (
|
||||
key TEXT PRIMARY KEY NOT NULL,
|
||||
dir TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
expires INTEGER NOT NULL
|
||||
)
|
||||
''');
|
||||
compute((path) => Directory(path).size, cachePath)
|
||||
.then((value) => _currentSize = value);
|
||||
}
|
||||
|
||||
factory CacheManager() => instance ??= CacheManager._create();
|
||||
|
||||
/// set cache size limit in bytes
|
||||
void setLimitSize(int size){
|
||||
_limitSize = size;
|
||||
}
|
||||
|
||||
Future<void> writeCache(String key, Uint8List data, [int duration = 7 * 24 * 60 * 60 * 1000]) async{
|
||||
this.dir++;
|
||||
this.dir %= 100;
|
||||
var dir = this.dir;
|
||||
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
while(await file.exists()){
|
||||
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
|
||||
file = File('$cachePath/$dir/$name');
|
||||
}
|
||||
await file.create(recursive: true);
|
||||
await file.writeAsBytes(data);
|
||||
var expires = DateTime.now().millisecondsSinceEpoch + duration;
|
||||
_db.execute('''
|
||||
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
|
||||
''', [key, dir.toString(), name, expires]);
|
||||
if(_currentSize != null) {
|
||||
_currentSize = _currentSize! + data.length;
|
||||
}
|
||||
if(_currentSize != null && _currentSize! > _limitSize){
|
||||
await checkCache();
|
||||
}
|
||||
}
|
||||
|
||||
Future<CachingFile> openWrite(String key) async{
|
||||
this.dir++;
|
||||
this.dir %= 100;
|
||||
var dir = this.dir;
|
||||
var name = md5.convert(Uint8List.fromList(key.codeUnits)).toString();
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
while(await file.exists()){
|
||||
name = md5.convert(Uint8List.fromList(name.codeUnits)).toString();
|
||||
file = File('$cachePath/$dir/$name');
|
||||
}
|
||||
await file.create(recursive: true);
|
||||
return CachingFile._(key, dir.toString(), name, file);
|
||||
}
|
||||
|
||||
Future<String?> findCache(String key) async{
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(res.isEmpty){
|
||||
return null;
|
||||
}
|
||||
var row = res.first;
|
||||
var dir = row.values[1] as String;
|
||||
var name = row.values[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
return file.path;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isChecking = false;
|
||||
|
||||
Future<void> checkCache() async{
|
||||
if(_isChecking){
|
||||
return;
|
||||
}
|
||||
_isChecking = true;
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE expires < ?
|
||||
''', [DateTime.now().millisecondsSinceEpoch]);
|
||||
for(var row in res){
|
||||
var dir = row.values[1] as int;
|
||||
var name = row.values[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
await file.delete();
|
||||
}
|
||||
}
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE expires < ?
|
||||
''', [DateTime.now().millisecondsSinceEpoch]);
|
||||
|
||||
while(_currentSize != null && _currentSize! > _limitSize){
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
ORDER BY time ASC
|
||||
limit 10
|
||||
''');
|
||||
for(var row in res){
|
||||
var key = row.values[0] as String;
|
||||
var dir = row.values[1] as int;
|
||||
var name = row.values[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
if(await file.exists()){
|
||||
var size = await file.length();
|
||||
await file.delete();
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
_currentSize = _currentSize! - size;
|
||||
if(_currentSize! <= _limitSize){
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
_isChecking = false;
|
||||
}
|
||||
|
||||
Future<void> delete(String key) async{
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(res.isEmpty){
|
||||
return;
|
||||
}
|
||||
var row = res.first;
|
||||
var dir = row.values[1] as String;
|
||||
var name = row.values[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
var fileSize = 0;
|
||||
if(await file.exists()){
|
||||
fileSize = await file.length();
|
||||
await file.delete();
|
||||
}
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(_currentSize != null) {
|
||||
_currentSize = _currentSize! - fileSize;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await Directory(cachePath).delete(recursive: true);
|
||||
Directory(cachePath).createSync(recursive: true);
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
''');
|
||||
_currentSize = 0;
|
||||
}
|
||||
|
||||
Future<void> deleteKeyword(String keyword) async{
|
||||
var res = _db.select('''
|
||||
SELECT * FROM cache
|
||||
WHERE key LIKE ?
|
||||
''', ['%$keyword%']);
|
||||
for(var row in res){
|
||||
var key = row.values[0] as String;
|
||||
var dir = row.values[1] as String;
|
||||
var name = row.values[2] as String;
|
||||
var file = File('$cachePath/$dir/$name');
|
||||
var fileSize = 0;
|
||||
if(await file.exists()){
|
||||
fileSize = await file.length();
|
||||
await file.delete();
|
||||
}
|
||||
_db.execute('''
|
||||
DELETE FROM cache
|
||||
WHERE key = ?
|
||||
''', [key]);
|
||||
if(_currentSize != null) {
|
||||
_currentSize = _currentSize! - fileSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CachingFile{
|
||||
CachingFile._(this.key, this.dir, this.name, this.file);
|
||||
|
||||
final String key;
|
||||
|
||||
final String dir;
|
||||
|
||||
final String name;
|
||||
|
||||
final File file;
|
||||
|
||||
final List<int> _buffer = [];
|
||||
|
||||
Future<void> writeBytes(List<int> data) async{
|
||||
_buffer.addAll(data);
|
||||
if(_buffer.length > 1024 * 1024){
|
||||
await file.writeAsBytes(_buffer, mode: FileMode.append);
|
||||
_buffer.clear();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async{
|
||||
if(_buffer.isNotEmpty){
|
||||
await file.writeAsBytes(_buffer, mode: FileMode.append);
|
||||
}
|
||||
CacheManager()._db.execute('''
|
||||
INSERT OR REPLACE INTO cache (key, dir, name, expires) VALUES (?, ?, ?, ?)
|
||||
''', [key, dir, name, DateTime.now().millisecondsSinceEpoch + 7 * 24 * 60 * 60 * 1000]);
|
||||
}
|
||||
|
||||
Future<void> cancel() async{
|
||||
await file.deleteIfExists();
|
||||
}
|
||||
}
|
193
lib/foundation/image_provider.dart
Normal file
193
lib/foundation/image_provider.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
import 'dart:async' show Future, StreamController, scheduleMicrotask;
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui show Codec;
|
||||
import 'dart:ui';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:pixes/network/app_dio.dart';
|
||||
import 'package:pixes/network/network.dart';
|
||||
|
||||
import 'cache_manager.dart';
|
||||
|
||||
class BadRequestException implements Exception {
|
||||
final String message;
|
||||
|
||||
BadRequestException(this.message);
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class BaseImageProvider<T extends BaseImageProvider<T>>
|
||||
extends ImageProvider<T> {
|
||||
const BaseImageProvider();
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(T key, ImageDecoderCallback decode) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadBufferAsync(key, chunkEvents, decode),
|
||||
chunkEvents: chunkEvents.stream,
|
||||
scale: 1.0,
|
||||
informationCollector: () sync* {
|
||||
yield DiagnosticsProperty<ImageProvider>(
|
||||
'Image provider: $this \n Image key: $key',
|
||||
this,
|
||||
style: DiagnosticsTreeStyle.errorProperty,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<ui.Codec> _loadBufferAsync(
|
||||
T key,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
ImageDecoderCallback decode,
|
||||
) async {
|
||||
try {
|
||||
int retryTime = 1;
|
||||
|
||||
bool stop = false;
|
||||
|
||||
chunkEvents.onCancel = () {
|
||||
stop = true;
|
||||
};
|
||||
|
||||
Uint8List? data;
|
||||
|
||||
while (data == null && !stop) {
|
||||
try {
|
||||
data = await load(chunkEvents);
|
||||
} catch (e) {
|
||||
if (e.toString().contains("Your IP address")) {
|
||||
rethrow;
|
||||
}
|
||||
if (e is BadRequestException) {
|
||||
rethrow;
|
||||
}
|
||||
if (e.toString().contains("handshake")) {
|
||||
if (retryTime < 5) {
|
||||
retryTime = 5;
|
||||
}
|
||||
}
|
||||
retryTime <<= 1;
|
||||
if (retryTime > (2 << 3) || stop) {
|
||||
rethrow;
|
||||
}
|
||||
await Future.delayed(Duration(seconds: retryTime));
|
||||
}
|
||||
}
|
||||
|
||||
if(stop) {
|
||||
throw Exception("Image loading is stopped");
|
||||
}
|
||||
|
||||
if(data!.isEmpty) {
|
||||
throw Exception("Empty image data");
|
||||
}
|
||||
|
||||
try {
|
||||
final buffer = await ImmutableBuffer.fromUint8List(data);
|
||||
return await decode(buffer);
|
||||
} catch (e) {
|
||||
await CacheManager().delete(this.key);
|
||||
Object error = e;
|
||||
if (data.length < 200) {
|
||||
// data is too short, it's likely that the data is text, not image
|
||||
try {
|
||||
var text = utf8.decoder.convert(data);
|
||||
error = Exception("Expected image data, but got text: $text");
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (e) {
|
||||
scheduleMicrotask(() {
|
||||
PaintingBinding.instance.imageCache.evict(key);
|
||||
});
|
||||
rethrow;
|
||||
} finally {
|
||||
chunkEvents.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents);
|
||||
|
||||
String get key;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
return other is BaseImageProvider<T> && key == other.key;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => key.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return "$runtimeType($key)";
|
||||
}
|
||||
}
|
||||
|
||||
typedef FileDecoderCallback = Future<ui.Codec> Function(Uint8List);
|
||||
|
||||
class CachedImageProvider extends BaseImageProvider<CachedImageProvider> {
|
||||
final String url;
|
||||
|
||||
CachedImageProvider(this.url);
|
||||
|
||||
@override
|
||||
String get key => url;
|
||||
|
||||
@override
|
||||
Future<Uint8List> load(StreamController<ImageChunkEvent> chunkEvents) async{
|
||||
var cached = await CacheManager().findCache(key);
|
||||
if(cached != null) {
|
||||
return await File(cached).readAsBytes();
|
||||
}
|
||||
var dio = AppDio();
|
||||
final time = DateFormat("yyyy-MM-dd'T'HH:mm:ss'+00:00'").format(DateTime.now());
|
||||
final hash = md5.convert(utf8.encode(time + Network.hashSalt)).toString();
|
||||
var res = await dio.get(
|
||||
url,
|
||||
options: Options(
|
||||
responseType: ResponseType.stream,
|
||||
validateStatus: (status) => status != null && status < 500,
|
||||
headers: {
|
||||
"referer": "https://app-api.pixiv.net/",
|
||||
"user-agent": "PixivAndroidApp/5.0.234 (Android 14; Pixes)",
|
||||
"x-client-time": time,
|
||||
"x-client-hash": hash,
|
||||
"accept-enconding": "gzip",
|
||||
}
|
||||
)
|
||||
);
|
||||
if(res.statusCode != 200) {
|
||||
throw BadRequestException("Failed to load image: ${res.statusCode}");
|
||||
}
|
||||
var data = <int>[];
|
||||
var cachingFile = await CacheManager().openWrite(key);
|
||||
await for (var chunk in res.data.stream) {
|
||||
data.addAll(chunk);
|
||||
await cachingFile.writeBytes(chunk);
|
||||
chunkEvents.add(ImageChunkEvent(
|
||||
cumulativeBytesLoaded: data.length,
|
||||
expectedTotalBytes: res.data.contentLength+1,
|
||||
));
|
||||
}
|
||||
await cachingFile.close();
|
||||
return Uint8List.fromList(data);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CachedImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture<CachedImageProvider>(this);
|
||||
}
|
||||
}
|
91
lib/foundation/log.dart
Normal file
91
lib/foundation/log.dart
Normal file
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:pixes/utils/ext.dart';
|
||||
|
||||
class LogItem {
|
||||
final LogLevel level;
|
||||
final String title;
|
||||
final String content;
|
||||
final DateTime time = DateTime.now();
|
||||
|
||||
@override
|
||||
toString() => "${level.name} $title $time \n$content\n\n";
|
||||
|
||||
LogItem(this.level, this.title, this.content);
|
||||
}
|
||||
|
||||
enum LogLevel { error, warning, info }
|
||||
|
||||
class Log {
|
||||
static final List<LogItem> _logs = <LogItem>[];
|
||||
|
||||
static List<LogItem> get logs => _logs;
|
||||
|
||||
static const maxLogLength = 3000;
|
||||
|
||||
static const maxLogNumber = 500;
|
||||
|
||||
static bool ignoreLimitation = false;
|
||||
|
||||
static void printWarning(String text) {
|
||||
print('\x1B[33m$text\x1B[0m');
|
||||
}
|
||||
|
||||
static void printError(String text) {
|
||||
print('\x1B[31m$text\x1B[0m');
|
||||
}
|
||||
|
||||
static void addLog(LogLevel level, String title, String content) {
|
||||
if (!ignoreLimitation && content.length > maxLogLength) {
|
||||
content = "${content.substring(0, maxLogLength)}...";
|
||||
}
|
||||
|
||||
if (kDebugMode) {
|
||||
switch (level) {
|
||||
case LogLevel.error:
|
||||
printError(content);
|
||||
case LogLevel.warning:
|
||||
printWarning(content);
|
||||
case LogLevel.info:
|
||||
print(content);
|
||||
}
|
||||
}
|
||||
|
||||
var newLog = LogItem(level, title, content);
|
||||
|
||||
if (newLog == _logs.lastOrNull) {
|
||||
return;
|
||||
}
|
||||
|
||||
_logs.add(newLog);
|
||||
if (_logs.length > maxLogNumber) {
|
||||
var res = _logs.remove(
|
||||
_logs.firstWhereOrNull((element) => element.level == LogLevel.info));
|
||||
if (!res) {
|
||||
_logs.removeAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static info(String title, String content) {
|
||||
addLog(LogLevel.info, title, content);
|
||||
}
|
||||
|
||||
static warning(String title, String content) {
|
||||
addLog(LogLevel.warning, title, content);
|
||||
}
|
||||
|
||||
static error(String title, String content) {
|
||||
addLog(LogLevel.error, title, content);
|
||||
}
|
||||
|
||||
static void clear() => _logs.clear();
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
var res = "Logs\n\n";
|
||||
for (var log in _logs) {
|
||||
res += log.toString();
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
19
lib/foundation/navigation.dart
Normal file
19
lib/foundation/navigation.dart
Normal file
@@ -0,0 +1,19 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
|
||||
import '../components/message.dart' as overlay;
|
||||
import '../components/page_route.dart';
|
||||
|
||||
extension Navigation on BuildContext {
|
||||
void pop<T>([T? result]) {
|
||||
Navigator.of(this).pop(result);
|
||||
}
|
||||
|
||||
Future<T?> to<T>(Widget Function() builder) {
|
||||
return Navigator.of(this)
|
||||
.push<T>(AppPageRoute(builder: (context) => builder()));
|
||||
}
|
||||
|
||||
void showToast({required String message, IconData? icon}) {
|
||||
overlay.showToast(this, message: message, icon: icon);
|
||||
}
|
||||
}
|
9
lib/foundation/pair.dart
Normal file
9
lib/foundation/pair.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
class Pair<M, V>{
|
||||
M left;
|
||||
V right;
|
||||
|
||||
Pair(this.left, this.right);
|
||||
|
||||
Pair.fromMap(Map<M, V> map, M key): left = key, right = map[key]
|
||||
?? (throw Exception("Pair not found"));
|
||||
}
|
195
lib/foundation/state_controller.dart
Normal file
195
lib/foundation/state_controller.dart
Normal file
@@ -0,0 +1,195 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'pair.dart';
|
||||
|
||||
class SimpleController extends StateController{
|
||||
final void Function()? refresh_;
|
||||
|
||||
SimpleController({this.refresh_});
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
(refresh_ ?? super.refresh)();
|
||||
}
|
||||
}
|
||||
|
||||
abstract class StateController{
|
||||
static final _controllers = <StateControllerWrapped>[];
|
||||
|
||||
static T put<T extends StateController>(T controller, {Object? tag, bool autoRemove = false}){
|
||||
_controllers.add(StateControllerWrapped(controller, autoRemove, tag));
|
||||
return controller;
|
||||
}
|
||||
|
||||
static T putIfNotExists<T extends StateController>(T controller, {Object? tag, bool autoRemove = false}){
|
||||
return findOrNull<T>(tag: tag) ?? put(controller, tag: tag, autoRemove: autoRemove);
|
||||
}
|
||||
|
||||
static T find<T extends StateController>({Object? tag}){
|
||||
try {
|
||||
return _controllers.lastWhere((element) =>
|
||||
element.controller is T
|
||||
&& (tag == null || tag == element.tag)).controller as T;
|
||||
}
|
||||
catch(e){
|
||||
throw StateError("${T.runtimeType} with tag $tag Not Found");
|
||||
}
|
||||
}
|
||||
|
||||
static T? findOrNull<T extends StateController>({Object? tag}){
|
||||
try {
|
||||
return _controllers.lastWhere((element) =>
|
||||
element.controller is T
|
||||
&& (tag == null || tag == element.tag)).controller as T;
|
||||
}
|
||||
catch(e){
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static void remove<T>([Object? tag, bool check = false]){
|
||||
for(int i=_controllers.length-1; i>=0; i--){
|
||||
var element = _controllers[i];
|
||||
if(element.controller is T && (tag == null || tag == element.tag)){
|
||||
if(check && !element.autoRemove){
|
||||
continue;
|
||||
}
|
||||
_controllers.removeAt(i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static SimpleController putSimpleController(void Function() onUpdate, Object? tag, {void Function()? refresh}){
|
||||
var controller = SimpleController(refresh_: refresh);
|
||||
controller.stateUpdaters.add(Pair(null, onUpdate));
|
||||
_controllers.add(StateControllerWrapped(controller, false, tag));
|
||||
return controller;
|
||||
}
|
||||
|
||||
List<Pair<Object?, void Function()>> stateUpdaters = [];
|
||||
|
||||
void update([List<Object>? ids]){
|
||||
if(ids == null){
|
||||
for(var element in stateUpdaters){
|
||||
element.right();
|
||||
}
|
||||
}else{
|
||||
for(var element in stateUpdaters){
|
||||
if(ids.contains(element.left)) {
|
||||
element.right();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void dispose(){
|
||||
_controllers.removeWhere((element) => element.controller == this);
|
||||
}
|
||||
|
||||
void refresh(){
|
||||
update();
|
||||
}
|
||||
}
|
||||
|
||||
class StateControllerWrapped{
|
||||
StateController controller;
|
||||
bool autoRemove;
|
||||
Object? tag;
|
||||
|
||||
StateControllerWrapped(this.controller, this.autoRemove, this.tag);
|
||||
}
|
||||
|
||||
|
||||
class StateBuilder<T extends StateController> extends StatefulWidget {
|
||||
const StateBuilder({super.key, this.init, this.dispose, this.initState, this.tag,
|
||||
required this.builder, this.id});
|
||||
|
||||
final T? init;
|
||||
|
||||
final void Function(T controller)? dispose;
|
||||
|
||||
final void Function(T controller)? initState;
|
||||
|
||||
final Object? tag;
|
||||
|
||||
final Widget Function(T controller) builder;
|
||||
|
||||
Widget builderWrapped(StateController controller){
|
||||
return builder(controller as T);
|
||||
}
|
||||
|
||||
void initStateWrapped(StateController controller){
|
||||
return initState?.call(controller as T);
|
||||
}
|
||||
|
||||
void disposeWrapped(StateController controller){
|
||||
return dispose?.call(controller as T);
|
||||
}
|
||||
|
||||
final Object? id;
|
||||
|
||||
@override
|
||||
State<StateBuilder> createState() => _StateBuilderState<T>();
|
||||
}
|
||||
|
||||
class _StateBuilderState<T extends StateController> extends State<StateBuilder> {
|
||||
late T controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
if(widget.init != null) {
|
||||
StateController.put(widget.init!, tag: widget.tag, autoRemove: true);
|
||||
}
|
||||
try {
|
||||
controller = StateController.find<T>(tag: widget.tag);
|
||||
}
|
||||
catch(e){
|
||||
throw "Controller Not Found";
|
||||
}
|
||||
controller.stateUpdaters.add(Pair(widget.id, () {
|
||||
if(mounted){
|
||||
setState(() {});
|
||||
}
|
||||
}));
|
||||
widget.initStateWrapped(controller);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.disposeWrapped(controller);
|
||||
StateController.remove<T>(widget.tag, true);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.builderWrapped(controller);
|
||||
}
|
||||
|
||||
abstract class StateWithController<T extends StatefulWidget> extends State<T>{
|
||||
late final SimpleController _controller;
|
||||
|
||||
void refresh(){
|
||||
_controller.update();
|
||||
}
|
||||
|
||||
@override
|
||||
@mustCallSuper
|
||||
void initState() {
|
||||
_controller = StateController.putSimpleController(() => setState(() {}), tag, refresh: refresh);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
@mustCallSuper
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void update(){
|
||||
_controller.update();
|
||||
}
|
||||
|
||||
Object? get tag;
|
||||
}
|
59
lib/foundation/widget_utils.dart
Normal file
59
lib/foundation/widget_utils.dart
Normal file
@@ -0,0 +1,59 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
extension WidgetExtension on Widget{
|
||||
Widget padding(EdgeInsetsGeometry padding){
|
||||
return Padding(padding: padding, child: this);
|
||||
}
|
||||
|
||||
Widget paddingLeft(double padding){
|
||||
return Padding(padding: EdgeInsets.only(left: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingRight(double padding){
|
||||
return Padding(padding: EdgeInsets.only(right: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingTop(double padding){
|
||||
return Padding(padding: EdgeInsets.only(top: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingBottom(double padding){
|
||||
return Padding(padding: EdgeInsets.only(bottom: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingVertical(double padding){
|
||||
return Padding(padding: EdgeInsets.symmetric(vertical: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingHorizontal(double padding){
|
||||
return Padding(padding: EdgeInsets.symmetric(horizontal: padding), child: this);
|
||||
}
|
||||
|
||||
Widget paddingAll(double padding){
|
||||
return Padding(padding: EdgeInsets.all(padding), child: this);
|
||||
}
|
||||
|
||||
Widget toCenter(){
|
||||
return Center(child: this);
|
||||
}
|
||||
|
||||
Widget toAlign(AlignmentGeometry alignment){
|
||||
return Align(alignment: alignment, child: this);
|
||||
}
|
||||
|
||||
Widget sliverPadding(EdgeInsetsGeometry padding){
|
||||
return SliverPadding(padding: padding, sliver: this);
|
||||
}
|
||||
|
||||
Widget sliverPaddingAll(double padding){
|
||||
return SliverPadding(padding: EdgeInsets.all(padding), sliver: this);
|
||||
}
|
||||
|
||||
Widget sliverPaddingVertical(double padding){
|
||||
return SliverPadding(padding: EdgeInsets.symmetric(vertical: padding), sliver: this);
|
||||
}
|
||||
|
||||
Widget sliverPaddingHorizontal(double padding){
|
||||
return SliverPadding(padding: EdgeInsets.symmetric(horizontal: padding), sliver: this);
|
||||
}
|
||||
}
|
95
lib/main.dart
Normal file
95
lib/main.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import "package:fluent_ui/fluent_ui.dart";
|
||||
import "package:pixes/appdata.dart";
|
||||
import "package:pixes/components/message.dart";
|
||||
import "package:pixes/foundation/app.dart";
|
||||
import "package:pixes/foundation/log.dart";
|
||||
import "package:pixes/network/app_dio.dart";
|
||||
import "package:pixes/pages/main_page.dart";
|
||||
import "package:pixes/utils/app_links.dart";
|
||||
import "package:window_manager/window_manager.dart";
|
||||
import 'package:system_theme/system_theme.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
FlutterError.onError = (details) {
|
||||
Log.error("Unhandled", "${details.exception}\n${details.stack}");
|
||||
};
|
||||
setSystemProxy();
|
||||
SystemTheme.fallbackColor = Colors.blue;
|
||||
await SystemTheme.accentColor.load();
|
||||
await App.init();
|
||||
await appdata.readData();
|
||||
handleLinks();
|
||||
SystemTheme.onChange.listen((event) {
|
||||
StateController.findOrNull(tag: "MyApp")?.update();
|
||||
});
|
||||
if (App.isDesktop) {
|
||||
await WindowManager.instance.ensureInitialized();
|
||||
windowManager.waitUntilReadyToShow().then((_) async {
|
||||
await windowManager.setTitleBarStyle(
|
||||
TitleBarStyle.hidden,
|
||||
windowButtonVisibility: false,
|
||||
);
|
||||
await windowManager.setMinimumSize(const Size(500, 600));
|
||||
await windowManager.show();
|
||||
await windowManager.setSkipTaskbar(false);
|
||||
});
|
||||
}
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StateBuilder<SimpleController>(
|
||||
init: SimpleController(),
|
||||
tag: "MyApp",
|
||||
builder: (controller) {
|
||||
return FluentApp(
|
||||
navigatorKey: App.rootNavigatorKey,
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'pixes',
|
||||
theme: FluentThemeData(
|
||||
brightness: Brightness.light,
|
||||
accentColor: AccentColor.swatch({
|
||||
'darkest': SystemTheme.accentColor.darkest,
|
||||
'darker': SystemTheme.accentColor.darker,
|
||||
'dark': SystemTheme.accentColor.dark,
|
||||
'normal': SystemTheme.accentColor.accent,
|
||||
'light': SystemTheme.accentColor.light,
|
||||
'lighter': SystemTheme.accentColor.lighter,
|
||||
'lightest': SystemTheme.accentColor.lightest,
|
||||
})),
|
||||
darkTheme: FluentThemeData(
|
||||
brightness: Brightness.dark,
|
||||
accentColor: AccentColor.swatch({
|
||||
'darkest': SystemTheme.accentColor.darkest,
|
||||
'darker': SystemTheme.accentColor.darker,
|
||||
'dark': SystemTheme.accentColor.dark,
|
||||
'normal': SystemTheme.accentColor.accent,
|
||||
'light': SystemTheme.accentColor.light,
|
||||
'lighter': SystemTheme.accentColor.lighter,
|
||||
'lightest': SystemTheme.accentColor.lightest,
|
||||
})),
|
||||
home: const MainPage(),
|
||||
builder: (context, child) {
|
||||
ErrorWidget.builder = (details) {
|
||||
if (details.exception
|
||||
.toString()
|
||||
.contains("RenderFlex overflowed")) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
Log.error("UI", "${details.exception}\n${details.stack}");
|
||||
return Text(details.exception.toString());
|
||||
};
|
||||
if (child == null) {
|
||||
throw "widget is null";
|
||||
}
|
||||
|
||||
return OverlayWidget(child);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
140
lib/network/app_dio.dart
Normal file
140
lib/network/app_dio.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:dio/io.dart';
|
||||
import 'package:pixes/foundation/log.dart';
|
||||
|
||||
export 'package:dio/dio.dart';
|
||||
|
||||
class MyLogInterceptor implements Interceptor {
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) {
|
||||
Log.error("Network",
|
||||
"${err.requestOptions.method} ${err.requestOptions.path}\n$err\n${err.response?.data.toString()}");
|
||||
switch (err.type) {
|
||||
case DioExceptionType.badResponse:
|
||||
var statusCode = err.response?.statusCode;
|
||||
if (statusCode != null) {
|
||||
err = err.copyWith(
|
||||
message: "Invalid Status Code: $statusCode. "
|
||||
"${_getStatusCodeInfo(statusCode)}");
|
||||
}
|
||||
case DioExceptionType.connectionTimeout:
|
||||
err = err.copyWith(message: "Connection Timeout");
|
||||
case DioExceptionType.receiveTimeout:
|
||||
err = err.copyWith(
|
||||
message: "Receive Timeout: "
|
||||
"This indicates that the server is too busy to respond");
|
||||
case DioExceptionType.unknown:
|
||||
if (err.toString().contains("Connection terminated during handshake")) {
|
||||
err = err.copyWith(
|
||||
message: "Connection terminated during handshake: "
|
||||
"This may be caused by the firewall blocking the connection "
|
||||
"or your requests are too frequent.");
|
||||
} else if (err.toString().contains("Connection reset by peer")) {
|
||||
err = err.copyWith(
|
||||
message: "Connection reset by peer: "
|
||||
"The error is unrelated to app, please check your network.");
|
||||
}
|
||||
default:
|
||||
{}
|
||||
}
|
||||
handler.next(err);
|
||||
}
|
||||
|
||||
static const errorMessages = <int, String>{
|
||||
400: "The Request is invalid.",
|
||||
401: "The Request is unauthorized.",
|
||||
403: "No permission to access the resource. Check your account or network.",
|
||||
404: "Not found.",
|
||||
429: "Too many requests. Please try again later.",
|
||||
};
|
||||
|
||||
String _getStatusCodeInfo(int? statusCode) {
|
||||
if (statusCode != null && statusCode >= 500) {
|
||||
return "This is server-side error, please try again later. "
|
||||
"Do not report this issue.";
|
||||
} else {
|
||||
return errorMessages[statusCode] ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onResponse(
|
||||
Response<dynamic> response, ResponseInterceptorHandler handler) {
|
||||
var headers = response.headers.map.map((key, value) => MapEntry(
|
||||
key.toLowerCase(), value.length == 1 ? value.first : value.toString()));
|
||||
headers.remove("cookie");
|
||||
String content;
|
||||
if (response.data is List<int>) {
|
||||
content = "<Bytes>\nlength:${response.data.length}";
|
||||
} else {
|
||||
content = response.data.toString();
|
||||
}
|
||||
Log.addLog(
|
||||
(response.statusCode != null && response.statusCode! < 400)
|
||||
? LogLevel.info
|
||||
: LogLevel.error,
|
||||
"Network",
|
||||
"Response ${response.realUri.toString()} ${response.statusCode}\n"
|
||||
"headers:\n$headers\n$content");
|
||||
handler.next(response);
|
||||
}
|
||||
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
options.connectTimeout = const Duration(seconds: 15);
|
||||
options.receiveTimeout = const Duration(seconds: 15);
|
||||
options.sendTimeout = const Duration(seconds: 15);
|
||||
if (options.headers["Host"] == null && options.headers["host"] == null) {
|
||||
options.headers["host"] = options.uri.host;
|
||||
}
|
||||
Log.info("Network",
|
||||
"${options.method} ${options.uri}\n${options.headers}\n${options.data}");
|
||||
handler.next(options);
|
||||
}
|
||||
}
|
||||
|
||||
class AppDio extends DioForNative {
|
||||
bool isInitialized = false;
|
||||
|
||||
@override
|
||||
Future<Response<T>> request<T>(String path,
|
||||
{Object? data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
CancelToken? cancelToken,
|
||||
Options? options,
|
||||
ProgressCallback? onSendProgress,
|
||||
ProgressCallback? onReceiveProgress}) {
|
||||
if (!isInitialized) {
|
||||
isInitialized = true;
|
||||
interceptors.add(MyLogInterceptor());
|
||||
}
|
||||
return super.request(path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
cancelToken: cancelToken,
|
||||
options: options,
|
||||
onSendProgress: onSendProgress,
|
||||
onReceiveProgress: onReceiveProgress);
|
||||
}
|
||||
}
|
||||
|
||||
void setSystemProxy() {
|
||||
HttpOverrides.global = _ProxyHttpOverrides();
|
||||
}
|
||||
|
||||
class _ProxyHttpOverrides extends HttpOverrides {
|
||||
String findProxy(Uri uri) {
|
||||
// TODO: proxy
|
||||
return "DIRECT";
|
||||
}
|
||||
|
||||
@override
|
||||
HttpClient createHttpClient(SecurityContext? context) {
|
||||
final client = super.createHttpClient(context);
|
||||
client.connectionTimeout = const Duration(seconds: 5);
|
||||
client.findProxy = findProxy;
|
||||
return client;
|
||||
}
|
||||
}
|
5
lib/network/download.dart
Normal file
5
lib/network/download.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
import 'package:pixes/network/network.dart';
|
||||
|
||||
extension IllustExt on Illust {
|
||||
bool get downloaded => false;
|
||||
}
|
215
lib/network/models.dart
Normal file
215
lib/network/models.dart
Normal file
@@ -0,0 +1,215 @@
|
||||
class Account {
|
||||
String accessToken;
|
||||
String refreshToken;
|
||||
final User user;
|
||||
|
||||
Account(this.accessToken, this.refreshToken, this.user);
|
||||
|
||||
Account.fromJson(Map<String, dynamic> json)
|
||||
: accessToken = json['access_token'],
|
||||
refreshToken = json['refresh_token'],
|
||||
user = User.fromJson(json['user']);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'access_token': accessToken,
|
||||
'refresh_token': refreshToken,
|
||||
'user': user.toJson()
|
||||
};
|
||||
}
|
||||
|
||||
class User {
|
||||
String profile;
|
||||
final String id;
|
||||
String name;
|
||||
String account;
|
||||
String email;
|
||||
bool isPremium;
|
||||
|
||||
User(this.profile, this.id, this.name, this.account, this.email,
|
||||
this.isPremium);
|
||||
|
||||
User.fromJson(Map<String, dynamic> json)
|
||||
: profile = json['profile_image_urls']['px_170x170'],
|
||||
id = json['id'],
|
||||
name = json['name'],
|
||||
account = json['account'],
|
||||
email = json['mail_address'],
|
||||
isPremium = json['is_premium'];
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'profile_image_urls': {'px_170x170': profile},
|
||||
'id': id,
|
||||
'name': name,
|
||||
'account': account,
|
||||
'mail_address': email,
|
||||
'is_premium': isPremium
|
||||
};
|
||||
}
|
||||
|
||||
class UserDetails {
|
||||
final int id;
|
||||
final String name;
|
||||
final String account;
|
||||
final String avatar;
|
||||
final String comment;
|
||||
final bool isFollowed;
|
||||
final bool isBlocking;
|
||||
final String? webpage;
|
||||
final String gender;
|
||||
final String birth;
|
||||
final String region;
|
||||
final String job;
|
||||
final int totalFollowUsers;
|
||||
final int myPixivUsers;
|
||||
final int totalIllusts;
|
||||
final int totalMangas;
|
||||
final int totalNovels;
|
||||
final int totalIllustBookmarks;
|
||||
final String? backgroundImage;
|
||||
final String? twitterUrl;
|
||||
final bool isPremium;
|
||||
final String? pawooUrl;
|
||||
|
||||
UserDetails(
|
||||
this.id,
|
||||
this.name,
|
||||
this.account,
|
||||
this.avatar,
|
||||
this.comment,
|
||||
this.isFollowed,
|
||||
this.isBlocking,
|
||||
this.webpage,
|
||||
this.gender,
|
||||
this.birth,
|
||||
this.region,
|
||||
this.job,
|
||||
this.totalFollowUsers,
|
||||
this.myPixivUsers,
|
||||
this.totalIllusts,
|
||||
this.totalMangas,
|
||||
this.totalNovels,
|
||||
this.totalIllustBookmarks,
|
||||
this.backgroundImage,
|
||||
this.twitterUrl,
|
||||
this.isPremium,
|
||||
this.pawooUrl);
|
||||
|
||||
UserDetails.fromJson(Map<String, dynamic> json)
|
||||
: id = json['user']['id'],
|
||||
name = json['user']['name'],
|
||||
account = json['user']['account'],
|
||||
avatar = json['user']['profile_image_urls']['medium'],
|
||||
comment = json['user']['comment'],
|
||||
isFollowed = json['user']['is_followed'],
|
||||
isBlocking = json['user']['is_access_blocking_user'],
|
||||
webpage = json['profile']['webpage'],
|
||||
gender = json['profile']['gender'],
|
||||
birth = json['profile']['birth'],
|
||||
region = json['profile']['region'],
|
||||
job = json['profile']['job'],
|
||||
totalFollowUsers = json['profile']['total_follow_users'],
|
||||
myPixivUsers = json['profile']['total_mypixiv_users'],
|
||||
totalIllusts = json['profile']['total_illusts'],
|
||||
totalMangas = json['profile']['total_manga'],
|
||||
totalNovels = json['profile']['total_novels'],
|
||||
totalIllustBookmarks = json['profile']['total_illust_bookmarks_public'],
|
||||
backgroundImage = json['profile']['background_image_url'],
|
||||
twitterUrl = json['profile']['twitter_url'],
|
||||
isPremium = json['profile']['is_premium'],
|
||||
pawooUrl = json['profile']['pawoo_url'];
|
||||
}
|
||||
|
||||
class IllustAuthor {
|
||||
final int id;
|
||||
final String name;
|
||||
final String account;
|
||||
final String avatar;
|
||||
bool isFollowed;
|
||||
|
||||
IllustAuthor(
|
||||
this.id, this.name, this.account, this.avatar, this.isFollowed);
|
||||
}
|
||||
|
||||
class Tag {
|
||||
final String name;
|
||||
final String? translatedName;
|
||||
|
||||
const Tag(this.name, this.translatedName);
|
||||
}
|
||||
|
||||
class IllustImage {
|
||||
final String squareMedium;
|
||||
final String medium;
|
||||
final String large;
|
||||
final String original;
|
||||
|
||||
const IllustImage(this.squareMedium, this.medium, this.large, this.original);
|
||||
}
|
||||
|
||||
class Illust {
|
||||
final int id;
|
||||
final String title;
|
||||
final String type;
|
||||
final List<IllustImage> images;
|
||||
final String caption;
|
||||
final int restrict;
|
||||
final IllustAuthor author;
|
||||
final List<Tag> tags;
|
||||
final String createDate;
|
||||
final int pageCount;
|
||||
final int width;
|
||||
final int height;
|
||||
final int totalView;
|
||||
final int totalBookmarks;
|
||||
bool isBookmarked;
|
||||
final bool isAi;
|
||||
|
||||
Illust.fromJson(Map<String, dynamic> json)
|
||||
: id = json['id'],
|
||||
title = json['title'],
|
||||
type = json['type'],
|
||||
images = (() {
|
||||
List<IllustImage> images = [];
|
||||
for (var i in json['meta_pages']) {
|
||||
images.add(IllustImage(
|
||||
i['image_urls']['square_medium'],
|
||||
i['image_urls']['medium'],
|
||||
i['image_urls']['large'],
|
||||
i['image_urls']['original']));
|
||||
}
|
||||
if (images.isEmpty) {
|
||||
images.add(IllustImage(
|
||||
json['image_urls']['square_medium'],
|
||||
json['image_urls']['medium'],
|
||||
json['image_urls']['large'],
|
||||
json['meta_single_page']['original_image_url']));
|
||||
}
|
||||
return images;
|
||||
}()),
|
||||
caption = json['caption'],
|
||||
restrict = json['restrict'],
|
||||
author = IllustAuthor(
|
||||
json['user']['id'],
|
||||
json['user']['name'],
|
||||
json['user']['account'],
|
||||
json['user']['profile_image_urls']['medium'],
|
||||
json['user']['is_followed'] ?? false),
|
||||
tags = (json['tags'] as List)
|
||||
.map((e) => Tag(e['name'], e['translated_name']))
|
||||
.toList(),
|
||||
createDate = json['create_date'],
|
||||
pageCount = json['page_count'],
|
||||
width = json['width'],
|
||||
height = json['height'],
|
||||
totalView = json['total_view'],
|
||||
totalBookmarks = json['total_bookmarks'],
|
||||
isBookmarked = json['is_bookmarked'],
|
||||
isAi = json['is_ai'] != 1;
|
||||
}
|
||||
|
||||
class TrendingTag {
|
||||
final Tag tag;
|
||||
final Illust illust;
|
||||
|
||||
TrendingTag(this.tag, this.illust);
|
||||
}
|
258
lib/network/network.dart
Normal file
258
lib/network/network.dart
Normal file
@@ -0,0 +1,258 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:pixes/appdata.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
import 'package:pixes/foundation/log.dart';
|
||||
import 'package:pixes/network/app_dio.dart';
|
||||
import 'package:pixes/network/res.dart';
|
||||
|
||||
import 'models.dart';
|
||||
|
||||
export 'models.dart';
|
||||
export 'res.dart';
|
||||
|
||||
class Network {
|
||||
static const hashSalt =
|
||||
"28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c";
|
||||
|
||||
static const baseUrl = 'https://app-api.pixiv.net';
|
||||
static const oauthUrl = 'https://oauth.secure.pixiv.net';
|
||||
|
||||
static const String clientID = "MOBrBDS8blbauoSck0ZfDbtuzpyT";
|
||||
static const String clientSecret = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj";
|
||||
static const String refreshClientID = "KzEZED7aC0vird8jWyHM38mXjNTY";
|
||||
static const String refreshClientSecret =
|
||||
"W9JZoJe00qPvJsiyCGT3CCtC6ZUtdpKpzMbNlUGP";
|
||||
|
||||
static Network? instance;
|
||||
|
||||
factory Network() => instance ?? (instance = Network._create());
|
||||
|
||||
Network._create();
|
||||
|
||||
String? codeVerifier;
|
||||
|
||||
String? get token => appdata.account?.accessToken;
|
||||
|
||||
final dio = AppDio();
|
||||
|
||||
Map<String, String> get _headers {
|
||||
final time =
|
||||
DateFormat("yyyy-MM-dd'T'HH:mm:ss'+00:00'").format(DateTime.now());
|
||||
final hash = md5.convert(utf8.encode(time + hashSalt)).toString();
|
||||
return {
|
||||
"X-Client-Time": time,
|
||||
"X-Client-Hash": hash,
|
||||
"User-Agent": "PixivAndroidApp/5.0.234 (Android 14.0; Pixes)",
|
||||
"accept-language": App.locale.toLanguageTag(),
|
||||
"Accept-Encoding": "gzip",
|
||||
if (token != null) "Authorization": "Bearer $token"
|
||||
};
|
||||
}
|
||||
|
||||
Future<String> generateWebviewUrl() async {
|
||||
const String chars =
|
||||
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
||||
codeVerifier =
|
||||
List.generate(128, (i) => chars[Random.secure().nextInt(chars.length)])
|
||||
.join();
|
||||
final codeChallenge = base64Url
|
||||
.encode(sha256.convert(ascii.encode(codeVerifier!)).bytes)
|
||||
.replaceAll('=', '');
|
||||
return "https://app-api.pixiv.net/web/v1/login?code_challenge=$codeChallenge&code_challenge_method=S256&client=pixiv-android";
|
||||
}
|
||||
|
||||
Future<Res<bool>> loginWithCode(String code) async {
|
||||
try {
|
||||
var res = await dio.post<String>("$oauthUrl/auth/token",
|
||||
data: {
|
||||
"client_id": clientID,
|
||||
"client_secret": clientSecret,
|
||||
"code": code,
|
||||
"code_verifier": codeVerifier,
|
||||
"grant_type": "authorization_code",
|
||||
"include_policy": "true",
|
||||
"redirect_uri":
|
||||
"https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback",
|
||||
},
|
||||
options: Options(
|
||||
contentType: Headers.formUrlEncodedContentType,
|
||||
headers: _headers));
|
||||
if (res.statusCode != 200) {
|
||||
throw "Invalid Status code ${res.statusCode}";
|
||||
}
|
||||
final data = json.decode(res.data!);
|
||||
appdata.account = Account.fromJson(data);
|
||||
appdata.writeData();
|
||||
return const Res(true);
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Res<bool>> refreshToken() async {
|
||||
try {
|
||||
var res = await dio.post<String>("$oauthUrl/auth/token",
|
||||
data: {
|
||||
"client_id": clientID,
|
||||
"client_secret": clientSecret,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": appdata.account?.refreshToken,
|
||||
"include_policy": "true",
|
||||
},
|
||||
options: Options(
|
||||
contentType: Headers.formUrlEncodedContentType,
|
||||
headers: _headers));
|
||||
var account = Account.fromJson(json.decode(res.data!));
|
||||
appdata.account = account;
|
||||
appdata.writeData();
|
||||
return const Res(true);
|
||||
}
|
||||
catch(e, s){
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Res<Map<String, dynamic>>> apiGet(String path, {Map<String, dynamic>? query}) async {
|
||||
try {
|
||||
if(!path.startsWith("http")) {
|
||||
path = "$baseUrl$path";
|
||||
}
|
||||
final res = await dio.get<Map<String, dynamic>>(path,
|
||||
queryParameters: query, options: Options(headers: _headers, validateStatus: (status) => true));
|
||||
if (res.statusCode == 200) {
|
||||
return Res(res.data!);
|
||||
} else if(res.statusCode == 400) {
|
||||
if(res.data.toString().contains("Access Token")) {
|
||||
var refresh = await refreshToken();
|
||||
if(refresh.success) {
|
||||
return apiGet(path, query: query);
|
||||
} else {
|
||||
return Res.error(refresh.errorMessage);
|
||||
}
|
||||
} else {
|
||||
return Res.error("Invalid Status Code: ${res.statusCode}");
|
||||
}
|
||||
} else if((res.statusCode??500) < 500){
|
||||
return Res.error(res.data?["error"]?["message"] ?? "Invalid Status code ${res.statusCode}");
|
||||
} else {
|
||||
return Res.error("Invalid Status Code: ${res.statusCode}");
|
||||
}
|
||||
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Res<Map<String, dynamic>>> apiPost(String path, {Map<String, dynamic>? query, Map<String, dynamic>? data}) async {
|
||||
try {
|
||||
if(!path.startsWith("http")) {
|
||||
path = "$baseUrl$path";
|
||||
}
|
||||
final res = await dio.post<Map<String, dynamic>>(path,
|
||||
queryParameters: query,
|
||||
data: data,
|
||||
options: Options(
|
||||
headers: _headers,
|
||||
validateStatus: (status) => true,
|
||||
contentType: Headers.formUrlEncodedContentType
|
||||
));
|
||||
if (res.statusCode == 200) {
|
||||
return Res(res.data!);
|
||||
} else if(res.statusCode == 400) {
|
||||
if(res.data.toString().contains("Access Token")) {
|
||||
var refresh = await refreshToken();
|
||||
if(refresh.success) {
|
||||
return apiGet(path, query: query);
|
||||
} else {
|
||||
return Res.error(refresh.errorMessage);
|
||||
}
|
||||
} else {
|
||||
return Res.error("Invalid Status Code: ${res.statusCode}");
|
||||
}
|
||||
} else if((res.statusCode??500) < 500){
|
||||
return Res.error(res.data?["error"]?["message"] ?? "Invalid Status code ${res.statusCode}");
|
||||
} else {
|
||||
return Res.error("Invalid Status Code: ${res.statusCode}");
|
||||
}
|
||||
|
||||
} catch (e, s) {
|
||||
Log.error("Network", "$e\n$s");
|
||||
return Res.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// get user details
|
||||
Future<Res<UserDetails>> getUserDetails(Object userId) async{
|
||||
var res = await apiGet("/v1/user/detail", query: {"user_id": userId, "filter": "for_android"});
|
||||
if (res.success) {
|
||||
return Res(UserDetails.fromJson(res.data));
|
||||
} else {
|
||||
return Res.error(res.errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Res<List<Illust>>> getRecommendedIllusts() async {
|
||||
var res = await apiGet("/v1/illust/recommended?include_privacy_policy=true&filter=for_android&include_ranking_illusts=true");
|
||||
if (res.success) {
|
||||
return Res((res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList());
|
||||
} else {
|
||||
return Res.error(res.errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Res<List<Illust>>> getBookmarkedIllusts(String restrict, [String? nextUrl]) async {
|
||||
var res = await apiGet(nextUrl ?? "/v1/user/bookmarks/illust?user_id=49258688&restrict=$restrict");
|
||||
if (res.success) {
|
||||
return Res((res.data["illusts"] as List).map((e) => Illust.fromJson(e)).toList(), subData: res.data["next_url"]);
|
||||
} else {
|
||||
return Res.error(res.errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Res<bool>> addBookmark(String id, String method, [String type = "public"]) async {
|
||||
var res = method == "add" ? await apiPost("/v2/illust/bookmark/$method", data: {
|
||||
"illust_id": id,
|
||||
"restrict": type
|
||||
}) : await apiPost("/v1/illust/bookmark/$method", data: {
|
||||
"illust_id": id,
|
||||
});
|
||||
if(!res.error) {
|
||||
return const Res(true);
|
||||
} else {
|
||||
return Res.fromErrorRes(res);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Res<bool>> follow(String uid, String method, [String type = "public"]) async {
|
||||
var res = method == "add" ? await apiPost("/v1/user/follow/add", data: {
|
||||
"user_id": uid,
|
||||
"restrict": type
|
||||
}) : await apiPost("/v1/user/follow/delete", data: {
|
||||
"user_id": uid,
|
||||
});
|
||||
if(!res.error) {
|
||||
return const Res(true);
|
||||
} else {
|
||||
return Res.fromErrorRes(res);
|
||||
}
|
||||
}
|
||||
|
||||
Future<Res<List<TrendingTag>>> getHotTags() async {
|
||||
var res = await apiGet("/v1/trending-tags/illust?filter=for_android&include_translated_tag_results=true");
|
||||
if(res.error) {
|
||||
return Res.fromErrorRes(res);
|
||||
} else {
|
||||
return Res(List.from(res.data["trend_tags"].map((e) => TrendingTag(
|
||||
Tag(e["tag"], e["translated_name"]),
|
||||
Illust.fromJson(e["illust"])
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
39
lib/network/res.dart
Normal file
39
lib/network/res.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
|
||||
@immutable
|
||||
class Res<T>{
|
||||
///error info
|
||||
final String? errorMessage;
|
||||
|
||||
String get errorMessageWithoutNull => errorMessage??"Unknown Error";
|
||||
|
||||
/// data
|
||||
final T? _data;
|
||||
|
||||
/// is there an error
|
||||
bool get error => errorMessage!=null || _data==null;
|
||||
|
||||
/// whether succeed
|
||||
bool get success => !error;
|
||||
|
||||
/// data
|
||||
///
|
||||
/// must be called when no error happened, or it will throw error
|
||||
T get data => _data ?? (throw Exception(errorMessage));
|
||||
|
||||
/// get data, or null if there is an error
|
||||
T? get dataOrNull => _data;
|
||||
|
||||
final dynamic subData;
|
||||
|
||||
@override
|
||||
String toString() => _data.toString();
|
||||
|
||||
Res.fromErrorRes(Res another, {this.subData}):
|
||||
_data=null,errorMessage=another.errorMessageWithoutNull;
|
||||
|
||||
/// network result
|
||||
const Res(this._data,{this.errorMessage, this.subData});
|
||||
|
||||
Res.error(dynamic e):errorMessage=e.toString(), _data=null, subData=null;
|
||||
}
|
94
lib/pages/bookmarks.dart
Normal file
94
lib/pages/bookmarks.dart
Normal file
@@ -0,0 +1,94 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:pixes/components/segmented_button.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
import 'package:pixes/network/network.dart';
|
||||
import 'package:pixes/utils/translation.dart';
|
||||
|
||||
import '../components/illust_widget.dart';
|
||||
import '../components/loading.dart';
|
||||
|
||||
class BookMarkedArtworkPage extends StatefulWidget {
|
||||
const BookMarkedArtworkPage({super.key});
|
||||
|
||||
@override
|
||||
State<BookMarkedArtworkPage> createState() => _BookMarkedArtworkPageState();
|
||||
}
|
||||
|
||||
class _BookMarkedArtworkPageState extends State<BookMarkedArtworkPage>{
|
||||
String restrict = "public";
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
buildTab(),
|
||||
Expanded(
|
||||
child: _OneBookmarkedPage(restrict, key: Key(restrict),),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTab() {
|
||||
return SegmentedButton(
|
||||
options: [
|
||||
SegmentedButtonOption("public", "Public".tl),
|
||||
SegmentedButtonOption("private", "Private".tl),
|
||||
],
|
||||
onPressed: (key) {
|
||||
if(key != restrict) {
|
||||
setState(() {
|
||||
restrict = key;
|
||||
});
|
||||
}
|
||||
},
|
||||
value: restrict,
|
||||
).padding(const EdgeInsets.symmetric(vertical: 8, horizontal: 8));
|
||||
}
|
||||
}
|
||||
|
||||
class _OneBookmarkedPage extends StatefulWidget {
|
||||
const _OneBookmarkedPage(this.restrict, {super.key});
|
||||
|
||||
final String restrict;
|
||||
|
||||
@override
|
||||
State<_OneBookmarkedPage> createState() => _OneBookmarkedPageState();
|
||||
}
|
||||
|
||||
class _OneBookmarkedPageState extends MultiPageLoadingState<_OneBookmarkedPage, Illust> {
|
||||
@override
|
||||
Widget buildContent(BuildContext context, final List<Illust> data) {
|
||||
return LayoutBuilder(builder: (context, constrains){
|
||||
return MasonryGridView.builder(
|
||||
gridDelegate: const SliverSimpleGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 240,
|
||||
),
|
||||
itemCount: data.length,
|
||||
itemBuilder: (context, index) {
|
||||
if(index == data.length - 1){
|
||||
nextPage();
|
||||
}
|
||||
return IllustWidget(data[index]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
String? nextUrl;
|
||||
|
||||
@override
|
||||
Future<Res<List<Illust>>> loadData(page) async{
|
||||
if(nextUrl == "end") {
|
||||
return Res.error("No more data");
|
||||
}
|
||||
var res = await Network().getBookmarkedIllusts(widget.restrict, nextUrl);
|
||||
if(!res.error) {
|
||||
nextUrl = res.subData;
|
||||
nextUrl ?? "end";
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
12
lib/pages/explore_page.dart
Normal file
12
lib/pages/explore_page.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ExplorePage extends StatelessWidget {
|
||||
const ExplorePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Center(
|
||||
child: Text("Explore"),
|
||||
);
|
||||
}
|
||||
}
|
0
lib/pages/illust_detail_page.dart
Normal file
0
lib/pages/illust_detail_page.dart
Normal file
499
lib/pages/illust_page.dart
Normal file
499
lib/pages/illust_page.dart
Normal file
@@ -0,0 +1,499 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/material.dart' show Icons;
|
||||
import 'package:pixes/components/animated_image.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
import 'package:pixes/foundation/image_provider.dart';
|
||||
import 'package:pixes/network/download.dart';
|
||||
import 'package:pixes/network/network.dart';
|
||||
import 'package:pixes/pages/image_page.dart';
|
||||
import 'package:pixes/pages/user_info_page.dart';
|
||||
import 'package:pixes/utils/translation.dart';
|
||||
|
||||
import '../components/color_scheme.dart';
|
||||
|
||||
const _kBottomBarHeight = 64.0;
|
||||
|
||||
class IllustPage extends StatefulWidget {
|
||||
const IllustPage(this.illust, {super.key});
|
||||
|
||||
final Illust illust;
|
||||
|
||||
@override
|
||||
State<IllustPage> createState() => _IllustPageState();
|
||||
}
|
||||
|
||||
class _IllustPageState extends State<IllustPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: FluentTheme.of(context).micaBackgroundColor,
|
||||
child: SizedBox.expand(
|
||||
child: ColoredBox(
|
||||
color: FluentTheme.of(context).scaffoldBackgroundColor,
|
||||
child: LayoutBuilder(builder: (context, constrains) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: buildBody(constrains.maxWidth, constrains.maxHeight),
|
||||
),
|
||||
_BottomBar(widget.illust, constrains.maxHeight, constrains.maxWidth),
|
||||
],
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildBody(double width, double height) {
|
||||
return ListView.builder(
|
||||
itemCount: widget.illust.images.length + 2,
|
||||
itemBuilder: (context, index) {
|
||||
return buildImage(width, height, index);
|
||||
});
|
||||
}
|
||||
|
||||
Widget buildImage(double width, double height, int index) {
|
||||
if (index == 0) {
|
||||
return Text(
|
||||
widget.illust.title,
|
||||
style: const TextStyle(fontSize: 24),
|
||||
).paddingVertical(8).paddingHorizontal(12);
|
||||
}
|
||||
index--;
|
||||
if (index == widget.illust.images.length) {
|
||||
return const SizedBox(
|
||||
height: _kBottomBarHeight,
|
||||
);
|
||||
}
|
||||
var imageWidth = width;
|
||||
var imageHeight = widget.illust.height * width / widget.illust.width;
|
||||
if (imageHeight > height) {
|
||||
// 确保图片能够完整显示在屏幕上
|
||||
var scale = imageHeight / height;
|
||||
imageWidth = imageWidth / scale;
|
||||
imageHeight = height;
|
||||
}
|
||||
var image = SizedBox(
|
||||
width: imageWidth,
|
||||
height: imageHeight,
|
||||
child: GestureDetector(
|
||||
onTap: () => ImagePage.show(widget.illust.images[index].original),
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(widget.illust.images[index].medium),
|
||||
width: imageWidth,
|
||||
fit: BoxFit.cover,
|
||||
height: imageHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (index == 0) {
|
||||
return Hero(
|
||||
tag: "illust_${widget.illust.id}",
|
||||
child: image,
|
||||
);
|
||||
} else {
|
||||
return image;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _BottomBar extends StatefulWidget {
|
||||
const _BottomBar(this.illust, this.height, this.width);
|
||||
|
||||
final Illust illust;
|
||||
|
||||
final double height;
|
||||
|
||||
final double width;
|
||||
|
||||
@override
|
||||
State<_BottomBar> createState() => _BottomBarState();
|
||||
}
|
||||
|
||||
class _BottomBarState extends State<_BottomBar> {
|
||||
double? top;
|
||||
|
||||
double pageHeight = 0;
|
||||
|
||||
double widgetHeight = 48;
|
||||
|
||||
final key = GlobalKey();
|
||||
|
||||
double _width = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_width = widget.width;
|
||||
pageHeight = widget.height;
|
||||
top = pageHeight - _kBottomBarHeight;
|
||||
Future.delayed(const Duration(milliseconds: 200), () {
|
||||
final box = key.currentContext?.findRenderObject() as RenderBox?;
|
||||
widgetHeight = (box?.size.height) ?? 0;
|
||||
});
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _BottomBar oldWidget) {
|
||||
if (widget.height != pageHeight) {
|
||||
setState(() {
|
||||
pageHeight = widget.height;
|
||||
top = pageHeight - _kBottomBarHeight;
|
||||
});
|
||||
}
|
||||
if(_width != widget.width) {
|
||||
_width = widget.width;
|
||||
Future.microtask(() {
|
||||
final box = key.currentContext?.findRenderObject() as RenderBox?;
|
||||
var oldHeight = widgetHeight;
|
||||
widgetHeight = (box?.size.height) ?? 0;
|
||||
if(oldHeight != widgetHeight && top != pageHeight - _kBottomBarHeight) {
|
||||
setState(() {
|
||||
top = pageHeight - widgetHeight;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedPositioned(
|
||||
top: top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.ease,
|
||||
child: Card(
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(8)),
|
||||
backgroundColor:
|
||||
FluentTheme.of(context).micaBackgroundColor.withOpacity(0.96),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
key: key,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
buildTop(),
|
||||
buildStats(),
|
||||
buildTags(),
|
||||
SelectableText("${"Artwork ID".tl}: ${widget.illust.id}\n${"Artist ID".tl}: ${widget.illust.author.id}", style: TextStyle(color: ColorScheme.of(context).outline),).paddingLeft(4),
|
||||
const SizedBox(height: 8,)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTop() {
|
||||
return SizedBox(
|
||||
height: _kBottomBarHeight,
|
||||
width: double.infinity,
|
||||
child: LayoutBuilder(builder: (context, constrains) {
|
||||
return Row(
|
||||
children: [
|
||||
buildAuthor(),
|
||||
...buildActions(constrains.maxWidth),
|
||||
const Spacer(),
|
||||
if (top == pageHeight - _kBottomBarHeight)
|
||||
IconButton(
|
||||
icon: const Icon(FluentIcons.up),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
top = pageHeight - widgetHeight;
|
||||
});
|
||||
})
|
||||
else
|
||||
IconButton(
|
||||
icon: const Icon(FluentIcons.down),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
top = pageHeight - _kBottomBarHeight;
|
||||
});
|
||||
})
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
bool isFollowing = false;
|
||||
|
||||
Widget buildAuthor() {
|
||||
void follow() async{
|
||||
if(isFollowing) return;
|
||||
setState(() {
|
||||
isFollowing = true;
|
||||
});
|
||||
var method = widget.illust.author.isFollowed ? "delete" : "add";
|
||||
var res = await Network().follow(widget.illust.author.id.toString(), method);
|
||||
if(res.error) {
|
||||
if(mounted) {
|
||||
context.showToast(message: "Network Error");
|
||||
}
|
||||
} else {
|
||||
widget.illust.author.isFollowed = !widget.illust.author.isFollowed;
|
||||
}
|
||||
setState(() {
|
||||
isFollowing = false;
|
||||
});
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
backgroundColor: FluentTheme.of(context).cardColor.withOpacity(0.72),
|
||||
child: SizedBox(
|
||||
height: double.infinity,
|
||||
width: 246,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 40,
|
||||
width: 40,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
child: ColoredBox(
|
||||
color: ColorScheme.of(context).secondaryContainer,
|
||||
child: GestureDetector(
|
||||
onTap: () => context.to(() =>
|
||||
UserInfoPage(widget.illust.author.id.toString())),
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(widget.illust.author.avatar),
|
||||
width: 40,
|
||||
height: 40,
|
||||
fit: BoxFit.cover,
|
||||
filterQuality: FilterQuality.medium,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.illust.author.name,
|
||||
maxLines: 2,
|
||||
),
|
||||
),
|
||||
if(isFollowing)
|
||||
Button(onPressed: follow, child: const SizedBox(
|
||||
width: 42,
|
||||
height: 24,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 18,
|
||||
child: ProgressRing(strokeWidth: 2,),
|
||||
),
|
||||
),
|
||||
))
|
||||
else if (!widget.illust.author.isFollowed)
|
||||
Button(onPressed: follow, child: Text("Follow".tl))
|
||||
else
|
||||
Button(
|
||||
onPressed: follow,
|
||||
child: Text("Unfollow".tl, style: TextStyle(color: ColorScheme.of(context).errorColor),),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool isBookmarking = false;
|
||||
|
||||
Iterable<Widget> buildActions(double width) sync* {
|
||||
yield const SizedBox(width: 8,);
|
||||
|
||||
void favorite() async{
|
||||
if(isBookmarking) return;
|
||||
setState(() {
|
||||
isBookmarking = true;
|
||||
});
|
||||
var method = widget.illust.isBookmarked ? "delete" : "add";
|
||||
var res = await Network().addBookmark(widget.illust.id.toString(), method);
|
||||
if(res.error) {
|
||||
if(mounted) {
|
||||
context.showToast(message: "Network Error");
|
||||
}
|
||||
} else {
|
||||
widget.illust.isBookmarked = !widget.illust.isBookmarked;
|
||||
}
|
||||
setState(() {
|
||||
isBookmarking = false;
|
||||
});
|
||||
}
|
||||
|
||||
void download() {}
|
||||
|
||||
bool showText = width > 640;
|
||||
|
||||
yield Button(
|
||||
onPressed: favorite,
|
||||
child: SizedBox(
|
||||
height: 28,
|
||||
child: Row(
|
||||
children: [
|
||||
if(isBookmarking)
|
||||
const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: ProgressRing(strokeWidth: 2,),
|
||||
)
|
||||
else if(widget.illust.isBookmarked)
|
||||
Icon(
|
||||
Icons.favorite,
|
||||
color: ColorScheme.of(context).errorColor,
|
||||
size: 18,
|
||||
)
|
||||
else
|
||||
const Icon(
|
||||
Icons.favorite_border,
|
||||
size: 18,
|
||||
),
|
||||
if(showText)
|
||||
const SizedBox(width: 8,),
|
||||
if(showText)
|
||||
if(widget.illust.isBookmarked)
|
||||
Text("Cancel".tl)
|
||||
else
|
||||
Text("Favorite".tl)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
yield const SizedBox(width: 8,);
|
||||
|
||||
if (!widget.illust.downloaded) {
|
||||
yield Button(
|
||||
onPressed: download,
|
||||
child: SizedBox(
|
||||
height: 28,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
FluentIcons.download,
|
||||
size: 18,
|
||||
),
|
||||
if(showText)
|
||||
const SizedBox(width: 8,),
|
||||
if(showText)
|
||||
Text("Download".tl),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
yield const SizedBox(width: 8,);
|
||||
|
||||
yield Button(
|
||||
onPressed: favorite,
|
||||
child: SizedBox(
|
||||
height: 28,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
FluentIcons.comment,
|
||||
size: 18,
|
||||
),
|
||||
if(showText)
|
||||
const SizedBox(width: 8,),
|
||||
if(showText)
|
||||
Text("Comment".tl),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildStats(){
|
||||
return SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 2,),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: ColorScheme.of(context).outlineVariant, width: 0.6),
|
||||
borderRadius: BorderRadius.circular(4)
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(FluentIcons.view, size: 20,),
|
||||
Text("Views".tl, style: const TextStyle(fontSize: 12),)
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12,),
|
||||
Text(widget.illust.totalView.toString(), style: TextStyle(color: ColorScheme.of(context).primary, fontWeight: FontWeight.w500, fontSize: 18),)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16,),
|
||||
Expanded(child: Container(
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: ColorScheme.of(context).outlineVariant, width: 0.6),
|
||||
borderRadius: BorderRadius.circular(4)
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(FluentIcons.six_point_star, size: 20,),
|
||||
Text("Favorites".tl, style: const TextStyle(fontSize: 12),)
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12,),
|
||||
Text(widget.illust.totalBookmarks.toString(), style: TextStyle(color: ColorScheme.of(context).primary, fontWeight: FontWeight.w500, fontSize: 18),)
|
||||
],
|
||||
),
|
||||
)),
|
||||
const SizedBox(width: 2,),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTags() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: Wrap(
|
||||
spacing: 4,
|
||||
runSpacing: 4,
|
||||
children: widget.illust.tags.map((e) {
|
||||
var text = e.name;
|
||||
if(e.translatedName != null && e.name != e.translatedName) {
|
||||
text += "/${e.translatedName}";
|
||||
}
|
||||
return Card(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 6),
|
||||
child: Text(text, style: const TextStyle(fontSize: 13),),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
).paddingVertical(8).paddingHorizontal(2);
|
||||
}
|
||||
}
|
90
lib/pages/image_page.dart
Normal file
90
lib/pages/image_page.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:pixes/components/page_route.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
import 'package:pixes/foundation/image_provider.dart';
|
||||
import 'package:pixes/pages/main_page.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class ImagePage extends StatefulWidget {
|
||||
const ImagePage(this.url, {super.key});
|
||||
|
||||
final String url;
|
||||
|
||||
static show(String url) {
|
||||
App.rootNavigatorKey.currentState?.push(
|
||||
AppPageRoute(builder: (context) => ImagePage(url)));
|
||||
}
|
||||
|
||||
@override
|
||||
State<ImagePage> createState() => _ImagePageState();
|
||||
}
|
||||
|
||||
class _ImagePageState extends State<ImagePage> with WindowListener{
|
||||
int windowButtonKey = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
windowManager.addListener(this);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
setState(() {
|
||||
windowButtonKey++;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowUnmaximize() {
|
||||
setState(() {
|
||||
windowButtonKey++;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColoredBox(
|
||||
color: FluentTheme.of(context).micaBackgroundColor.withOpacity(1),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: PhotoView(
|
||||
backgroundDecoration: const BoxDecoration(
|
||||
color: Colors.transparent
|
||||
),
|
||||
filterQuality: FilterQuality.medium,
|
||||
imageProvider: CachedImageProvider(widget.url),
|
||||
)),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SizedBox(
|
||||
height: 36,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 6,),
|
||||
IconButton(
|
||||
icon: const Icon(FluentIcons.back).paddingAll(2),
|
||||
onPressed: () => context.pop()
|
||||
),
|
||||
const Expanded(
|
||||
child: DragToMoveArea(child: SizedBox.expand(),),
|
||||
),
|
||||
WindowButtons(key: ValueKey(windowButtonKey),),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
218
lib/pages/login_page.dart
Normal file
218
lib/pages/login_page.dart
Normal file
@@ -0,0 +1,218 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
import 'package:pixes/network/network.dart';
|
||||
import 'package:pixes/utils/app_links.dart';
|
||||
import 'package:pixes/utils/translation.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage(this.callback, {super.key});
|
||||
|
||||
final void Function() callback;
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
bool checked = false;
|
||||
|
||||
bool waitingForAuth = false;
|
||||
|
||||
bool isLogging = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!waitingForAuth) {
|
||||
return buildLogin(context);
|
||||
} else {
|
||||
return buildWaiting(context);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildLogin(BuildContext context) {
|
||||
return SizedBox(
|
||||
child: Center(
|
||||
child: Card(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
||||
child: SizedBox(
|
||||
width: 300,
|
||||
height: 300,
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
"Login".tl,
|
||||
style: const TextStyle(
|
||||
fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (checked)
|
||||
FilledButton(
|
||||
onPressed: onContinue,
|
||||
child: Text("Continue".tl),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
height: 28,
|
||||
width: 78,
|
||||
decoration: BoxDecoration(
|
||||
color: FluentTheme.of(context)
|
||||
.inactiveBackgroundColor,
|
||||
borderRadius: BorderRadius.circular(4)),
|
||||
child: Center(
|
||||
child: Text("Continue".tl),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 16,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
"You need to complete the login operation in the browser window that will open."
|
||||
.tl),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Checkbox(
|
||||
checked: checked,
|
||||
onChanged: (value) => setState(() {
|
||||
checked = value ?? false;
|
||||
})),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Text("I have read and agree to the Terms of Use".tl)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildWaiting(BuildContext context) {
|
||||
return SizedBox(
|
||||
child: Center(
|
||||
child: Card(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
||||
child: SizedBox(
|
||||
width: 300,
|
||||
height: 300,
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
"Waiting...".tl,
|
||||
style: const TextStyle(
|
||||
fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 4),
|
||||
child: Text(
|
||||
"Waiting for authentication. Please finished in the browser."
|
||||
.tl),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Button(
|
||||
child: Text("Back".tl),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
waitingForAuth = false;
|
||||
});
|
||||
}),
|
||||
const Spacer(),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildLoading(BuildContext context) {
|
||||
return SizedBox(
|
||||
child: Center(
|
||||
child: Card(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
|
||||
child: SizedBox(
|
||||
width: 300,
|
||||
height: 300,
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
"Logging in".tl,
|
||||
style: const TextStyle(
|
||||
fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
child: Center(
|
||||
child: ProgressRing(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onContinue() async {
|
||||
var url = await Network().generateWebviewUrl();
|
||||
launchUrlString(url);
|
||||
onLink = (uri) {
|
||||
if (uri.scheme == "pixiv") {
|
||||
onFinished(uri.queryParameters["code"]!);
|
||||
onLink = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
setState(() {
|
||||
waitingForAuth = true;
|
||||
});
|
||||
}
|
||||
|
||||
void onFinished(String code) async {
|
||||
setState(() {
|
||||
isLogging = true;
|
||||
waitingForAuth = false;
|
||||
});
|
||||
var res = await Network().loginWithCode(code);
|
||||
if (res.error) {
|
||||
if(mounted) {
|
||||
context.showToast(message: res.errorMessage!);
|
||||
}
|
||||
setState(() {
|
||||
isLogging = false;
|
||||
});
|
||||
} else {
|
||||
widget.callback();
|
||||
}
|
||||
}
|
||||
}
|
619
lib/pages/main_page.dart
Normal file
619
lib/pages/main_page.dart
Normal file
@@ -0,0 +1,619 @@
|
||||
import "dart:async";
|
||||
|
||||
import "package:fluent_ui/fluent_ui.dart";
|
||||
import "package:pixes/appdata.dart";
|
||||
import "package:pixes/components/color_scheme.dart";
|
||||
import "package:pixes/components/md.dart";
|
||||
import "package:pixes/foundation/app.dart";
|
||||
import "package:pixes/network/network.dart";
|
||||
import "package:pixes/pages/bookmarks.dart";
|
||||
import "package:pixes/pages/explore_page.dart";
|
||||
import "package:pixes/pages/recommendation_page.dart";
|
||||
import "package:pixes/pages/login_page.dart";
|
||||
import "package:pixes/pages/search_page.dart";
|
||||
import "package:pixes/pages/settings_page.dart";
|
||||
import "package:pixes/pages/user_info_page.dart";
|
||||
import "package:pixes/utils/mouse_listener.dart";
|
||||
import "package:pixes/utils/translation.dart";
|
||||
import "package:window_manager/window_manager.dart";
|
||||
|
||||
import "../components/page_route.dart";
|
||||
|
||||
const _kAppBarHeight = 36.0;
|
||||
|
||||
class MainPage extends StatefulWidget {
|
||||
const MainPage({super.key});
|
||||
|
||||
@override
|
||||
State<MainPage> createState() => _MainPageState();
|
||||
}
|
||||
|
||||
class _MainPageState extends State<MainPage> with WindowListener {
|
||||
final navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
int index = 1;
|
||||
|
||||
int windowButtonKey = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
windowManager.addListener(this);
|
||||
listenMouseSideButtonToBack(navigatorKey);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
windowManager.removeListener(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowMaximize() {
|
||||
setState(() {
|
||||
windowButtonKey++;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onWindowUnmaximize() {
|
||||
setState(() {
|
||||
windowButtonKey++;
|
||||
});
|
||||
}
|
||||
|
||||
bool get isLogin => Network().token != null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!isLogin) {
|
||||
return NavigationView(
|
||||
appBar: buildAppBar(context, navigatorKey),
|
||||
content: LoginPage(() => setState(() {})),
|
||||
);
|
||||
}
|
||||
return ColorScheme(
|
||||
brightness: FluentTheme.of(context).brightness,
|
||||
child: NavigationView(
|
||||
appBar: buildAppBar(context, navigatorKey),
|
||||
pane: NavigationPane(
|
||||
selected: index,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
index = value;
|
||||
});
|
||||
navigate(value);
|
||||
},
|
||||
items: [
|
||||
UserPane(),
|
||||
PaneItem(
|
||||
icon: const Icon(MdIcons.search, size: 20,),
|
||||
title: Text('Search'.tl),
|
||||
body: const SizedBox.shrink(),
|
||||
),
|
||||
PaneItemHeader(header: Text("Artwork".tl).paddingVertical(4).paddingLeft(8)),
|
||||
PaneItem(
|
||||
icon: const Icon(MdIcons.star_border, size: 20,),
|
||||
title: Text('Recommendations'.tl),
|
||||
body: const SizedBox.shrink(),
|
||||
),
|
||||
PaneItem(
|
||||
icon: const Icon(MdIcons.bookmark_outline, size: 20),
|
||||
title: Text('Bookmarks'.tl),
|
||||
body: const SizedBox.shrink(),
|
||||
),
|
||||
PaneItemSeparator(),
|
||||
PaneItem(
|
||||
icon: const Icon(MdIcons.explore_outlined, size: 20),
|
||||
title: Text('Explore'.tl),
|
||||
body: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
footerItems: [
|
||||
PaneItem(
|
||||
icon: const Icon(MdIcons.settings_outlined, size: 20),
|
||||
title: Text('Settings'.tl),
|
||||
body: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
paneBodyBuilder: (pane, child) => Navigator(
|
||||
key: navigatorKey,
|
||||
onGenerateRoute: (settings) => AppPageRoute(
|
||||
builder: (context) => const RecommendationPage()),
|
||||
)));
|
||||
}
|
||||
|
||||
static final pageBuilders = [
|
||||
() => UserInfoPage(appdata.account!.user.id),
|
||||
() => const SearchPage(),
|
||||
() => const RecommendationPage(),
|
||||
() => const BookMarkedArtworkPage(),
|
||||
() => const ExplorePage(),
|
||||
() => const SettingsPage(),
|
||||
];
|
||||
|
||||
void navigate(int index) {
|
||||
var page = pageBuilders.elementAtOrNull(index) ??
|
||||
() => Center(
|
||||
child: Text("Invalid Page: $index"),
|
||||
);
|
||||
navigatorKey.currentState!.pushAndRemoveUntil(
|
||||
AppPageRoute(builder: (context) => page()), (route) => false);
|
||||
}
|
||||
|
||||
NavigationAppBar buildAppBar(
|
||||
BuildContext context, GlobalKey<NavigatorState> navigatorKey) {
|
||||
return NavigationAppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
height: _kAppBarHeight,
|
||||
title: () {
|
||||
if (!App.isDesktop) {
|
||||
return const Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text("pixes"),
|
||||
);
|
||||
}
|
||||
return const DragToMoveArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 4),
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(
|
||||
"Pixes",
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}(),
|
||||
leading: _BackButton(navigatorKey),
|
||||
actions: WindowButtons(
|
||||
key: ValueKey(windowButtonKey),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BackButton extends StatefulWidget {
|
||||
const _BackButton(this.navigatorKey);
|
||||
|
||||
final GlobalKey<NavigatorState> navigatorKey;
|
||||
|
||||
@override
|
||||
State<_BackButton> createState() => _BackButtonState();
|
||||
}
|
||||
|
||||
class _BackButtonState extends State<_BackButton> {
|
||||
GlobalKey<NavigatorState> get navigatorKey => widget.navigatorKey;
|
||||
|
||||
bool enabled = false;
|
||||
|
||||
Timer? timer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
enabled = navigatorKey.currentState?.canPop() == true;
|
||||
loop();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
void loop() {
|
||||
timer = Timer.periodic(const Duration(milliseconds: 100), (timer) {
|
||||
if(!mounted) {
|
||||
timer.cancel();
|
||||
} else {
|
||||
bool enabled = navigatorKey.currentState?.canPop() == true;
|
||||
if(enabled != this.enabled) {
|
||||
setState(() {
|
||||
this.enabled = enabled;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void onPressed() {
|
||||
if (navigatorKey.currentState?.canPop() ?? false) {
|
||||
navigatorKey.currentState?.pop();
|
||||
}
|
||||
}
|
||||
|
||||
return NavigationPaneTheme(
|
||||
data: NavigationPaneTheme.of(context).merge(NavigationPaneThemeData(
|
||||
unselectedIconColor: ButtonState.resolveWith((states) {
|
||||
if (states.isDisabled) {
|
||||
return ButtonThemeData.buttonColor(context, states);
|
||||
}
|
||||
return ButtonThemeData.uncheckedInputColor(
|
||||
FluentTheme.of(context),
|
||||
states,
|
||||
).basedOnLuminance();
|
||||
}),
|
||||
)),
|
||||
child: Builder(
|
||||
builder: (context) => PaneItem(
|
||||
icon: const Center(child: Icon(FluentIcons.back, size: 12.0)),
|
||||
title: const Text("Back"),
|
||||
body: const SizedBox.shrink(),
|
||||
enabled: enabled,
|
||||
).build(
|
||||
context,
|
||||
false,
|
||||
onPressed,
|
||||
displayMode: PaneDisplayMode.compact,
|
||||
).paddingTop(2),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class WindowButtons extends StatelessWidget {
|
||||
const WindowButtons({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final FluentThemeData theme = FluentTheme.of(context);
|
||||
final color = theme.iconTheme.color ?? Colors.black;
|
||||
final hoverColor = theme.inactiveBackgroundColor;
|
||||
|
||||
return SizedBox(
|
||||
width: 138,
|
||||
height: _kAppBarHeight,
|
||||
child: Row(
|
||||
children: [
|
||||
WindowButton(
|
||||
icon: MinimizeIcon(color: color),
|
||||
hoverColor: hoverColor,
|
||||
onPressed: () async {
|
||||
bool isMinimized = await windowManager.isMinimized();
|
||||
if (isMinimized) {
|
||||
windowManager.restore();
|
||||
} else {
|
||||
windowManager.minimize();
|
||||
}
|
||||
},
|
||||
),
|
||||
FutureBuilder<bool>(
|
||||
future: windowManager.isMaximized(),
|
||||
builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
|
||||
if (snapshot.data == true) {
|
||||
return WindowButton(
|
||||
icon: RestoreIcon(
|
||||
color: color,
|
||||
),
|
||||
hoverColor: hoverColor,
|
||||
onPressed: () {
|
||||
windowManager.unmaximize();
|
||||
},
|
||||
);
|
||||
}
|
||||
return WindowButton(
|
||||
icon: MaximizeIcon(
|
||||
color: color,
|
||||
),
|
||||
hoverColor: hoverColor,
|
||||
onPressed: () {
|
||||
windowManager.maximize();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
WindowButton(
|
||||
icon: CloseIcon(
|
||||
color: color,
|
||||
),
|
||||
hoverIcon: CloseIcon(
|
||||
color: theme.brightness == Brightness.light
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
),
|
||||
hoverColor: Colors.red,
|
||||
onPressed: () {
|
||||
windowManager.close();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WindowButton extends StatefulWidget {
|
||||
const WindowButton(
|
||||
{required this.icon,
|
||||
required this.onPressed,
|
||||
required this.hoverColor,
|
||||
this.hoverIcon,
|
||||
super.key});
|
||||
|
||||
final Widget icon;
|
||||
|
||||
final void Function() onPressed;
|
||||
|
||||
final Color hoverColor;
|
||||
|
||||
final Widget? hoverIcon;
|
||||
|
||||
@override
|
||||
State<WindowButton> createState() => _WindowButtonState();
|
||||
}
|
||||
|
||||
class _WindowButtonState extends State<WindowButton> {
|
||||
bool isHovering = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (event) => setState(() {
|
||||
isHovering = true;
|
||||
}),
|
||||
onExit: (event) => setState(() {
|
||||
isHovering = false;
|
||||
}),
|
||||
child: GestureDetector(
|
||||
onTap: widget.onPressed,
|
||||
child: Container(
|
||||
width: 46,
|
||||
height: double.infinity,
|
||||
decoration:
|
||||
BoxDecoration(color: isHovering ? widget.hoverColor : null),
|
||||
child: isHovering ? widget.hoverIcon ?? widget.icon : widget.icon,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class UserPane extends PaneItem {
|
||||
UserPane() : super(icon: const SizedBox(), body: const SizedBox());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, bool selected, VoidCallback? onPressed,
|
||||
{PaneDisplayMode? displayMode,
|
||||
bool showTextOnTop = true,
|
||||
int? itemIndex,
|
||||
bool? autofocus}) {
|
||||
final maybeBody = NavigationView.maybeOf(context);
|
||||
var mode = displayMode ?? maybeBody?.displayMode ?? PaneDisplayMode.minimal;
|
||||
|
||||
if (maybeBody?.compactOverlayOpen == true) {
|
||||
mode = PaneDisplayMode.open;
|
||||
}
|
||||
|
||||
Widget body = () {
|
||||
switch (mode) {
|
||||
case PaneDisplayMode.minimal:
|
||||
case PaneDisplayMode.open:
|
||||
return LayoutBuilder(builder: (context, constrains) {
|
||||
if (constrains.maxHeight < 72 || constrains.maxWidth < 120) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: 64,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(48),
|
||||
child: Image(
|
||||
height: 48,
|
||||
width: 48,
|
||||
image: NetworkImage(appdata.account!.user.profile),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
if (constrains.maxWidth > 90)
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appdata.account!.user.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
Text(
|
||||
appdata.account!.user.email,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
case PaneDisplayMode.compact:
|
||||
case PaneDisplayMode.top:
|
||||
return LayoutBuilder(builder: (context, constrains) {
|
||||
if (constrains.maxHeight < 48 || constrains.maxWidth < 32) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return Center(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Image(
|
||||
height: 30,
|
||||
width: 30,
|
||||
image: NetworkImage(appdata.account!.user.profile),
|
||||
fit: BoxFit.fill,
|
||||
),
|
||||
).paddingAll(4),
|
||||
);
|
||||
});
|
||||
default:
|
||||
throw "Invalid Display mode";
|
||||
}
|
||||
}();
|
||||
|
||||
var button = HoverButton(
|
||||
builder: (context, states) {
|
||||
final theme = NavigationPaneTheme.of(context);
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 6.0),
|
||||
decoration: BoxDecoration(
|
||||
color: () {
|
||||
final tileColor = this.tileColor ??
|
||||
theme.tileColor ??
|
||||
kDefaultPaneItemColor(context, mode == PaneDisplayMode.top);
|
||||
final newStates = states.toSet()..remove(ButtonStates.disabled);
|
||||
if (selected && selectedTileColor != null) {
|
||||
return selectedTileColor!.resolve(newStates);
|
||||
}
|
||||
return tileColor.resolve(
|
||||
selected
|
||||
? {
|
||||
states.isHovering
|
||||
? ButtonStates.pressing
|
||||
: ButtonStates.hovering,
|
||||
}
|
||||
: newStates,
|
||||
);
|
||||
}(),
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
child: FocusBorder(
|
||||
focused: states.isFocused,
|
||||
renderOutside: false,
|
||||
child: body,
|
||||
),
|
||||
);
|
||||
},
|
||||
onPressed: onPressed,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
key: key,
|
||||
padding: const EdgeInsetsDirectional.only(bottom: 4.0),
|
||||
child: button,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Close
|
||||
class CloseIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
const CloseIcon({super.key, required this.color});
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_ClosePainter(color));
|
||||
}
|
||||
|
||||
class _ClosePainter extends _IconPainter {
|
||||
_ClosePainter(super.color);
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color, true);
|
||||
canvas.drawLine(const Offset(0, 0), Offset(size.width, size.height), p);
|
||||
canvas.drawLine(Offset(0, size.height), Offset(size.width, 0), p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximize
|
||||
class MaximizeIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
const MaximizeIcon({super.key, required this.color});
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_MaximizePainter(color));
|
||||
}
|
||||
|
||||
class _MaximizePainter extends _IconPainter {
|
||||
_MaximizePainter(super.color);
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
canvas.drawRect(Rect.fromLTRB(0, 0, size.width - 1, size.height - 1), p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore
|
||||
class RestoreIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
const RestoreIcon({
|
||||
super.key,
|
||||
required this.color,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_RestorePainter(color));
|
||||
}
|
||||
|
||||
class _RestorePainter extends _IconPainter {
|
||||
_RestorePainter(super.color);
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
canvas.drawRect(Rect.fromLTRB(0, 2, size.width - 2, size.height), p);
|
||||
canvas.drawLine(const Offset(2, 2), const Offset(2, 0), p);
|
||||
canvas.drawLine(const Offset(2, 0), Offset(size.width, 0), p);
|
||||
canvas.drawLine(
|
||||
Offset(size.width, 0), Offset(size.width, size.height - 2), p);
|
||||
canvas.drawLine(Offset(size.width, size.height - 2),
|
||||
Offset(size.width - 2, size.height - 2), p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimize
|
||||
class MinimizeIcon extends StatelessWidget {
|
||||
final Color color;
|
||||
const MinimizeIcon({super.key, required this.color});
|
||||
@override
|
||||
Widget build(BuildContext context) => _AlignedPaint(_MinimizePainter(color));
|
||||
}
|
||||
|
||||
class _MinimizePainter extends _IconPainter {
|
||||
_MinimizePainter(super.color);
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint p = getPaint(color);
|
||||
canvas.drawLine(
|
||||
Offset(0, size.height / 2), Offset(size.width, size.height / 2), p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helpers
|
||||
abstract class _IconPainter extends CustomPainter {
|
||||
_IconPainter(this.color);
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _AlignedPaint extends StatelessWidget {
|
||||
const _AlignedPaint(this.painter);
|
||||
final CustomPainter painter;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
child: CustomPaint(size: const Size(10, 10), painter: painter));
|
||||
}
|
||||
}
|
||||
|
||||
Paint getPaint(Color color, [bool isAntiAlias = false]) => Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..isAntiAlias = isAntiAlias
|
||||
..strokeWidth = 1;
|
15
lib/pages/ranking.dart
Normal file
15
lib/pages/ranking.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
|
||||
class RankingPage extends StatefulWidget {
|
||||
const RankingPage({super.key});
|
||||
|
||||
@override
|
||||
State<RankingPage> createState() => _RankingPageState();
|
||||
}
|
||||
|
||||
class _RankingPageState extends State<RankingPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Placeholder();
|
||||
}
|
||||
}
|
38
lib/pages/recommendation_page.dart
Normal file
38
lib/pages/recommendation_page.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:pixes/components/illust_widget.dart';
|
||||
import 'package:pixes/components/loading.dart';
|
||||
import 'package:pixes/network/network.dart';
|
||||
import 'package:pixes/network/res.dart';
|
||||
|
||||
class RecommendationPage extends StatefulWidget {
|
||||
const RecommendationPage({super.key});
|
||||
|
||||
@override
|
||||
State<RecommendationPage> createState() => _RecommendationPageState();
|
||||
}
|
||||
|
||||
class _RecommendationPageState extends MultiPageLoadingState<RecommendationPage, Illust> {
|
||||
@override
|
||||
Widget buildContent(BuildContext context, final List<Illust> data) {
|
||||
return LayoutBuilder(builder: (context, constrains){
|
||||
return MasonryGridView.builder(
|
||||
gridDelegate: const SliverSimpleGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 240,
|
||||
),
|
||||
itemCount: data.length,
|
||||
itemBuilder: (context, index) {
|
||||
if(index == data.length - 1){
|
||||
nextPage();
|
||||
}
|
||||
return IllustWidget(data[index]);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Res<List<Illust>>> loadData(page) {
|
||||
return Network().getRecommendedIllusts();
|
||||
}
|
||||
}
|
238
lib/pages/search_page.dart
Normal file
238
lib/pages/search_page.dart
Normal file
@@ -0,0 +1,238 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
|
||||
import 'package:pixes/components/loading.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
import 'package:pixes/network/network.dart';
|
||||
import 'package:pixes/pages/user_info_page.dart';
|
||||
import 'package:pixes/utils/translation.dart';
|
||||
|
||||
import '../components/animated_image.dart';
|
||||
import '../components/color_scheme.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;
|
||||
|
||||
void search() {
|
||||
switch(searchType) {
|
||||
case 0:
|
||||
context.to(() => SearchResultPage(text));
|
||||
case 1:
|
||||
// TODO: artwork by id
|
||||
throw UnimplementedError();
|
||||
case 2:
|
||||
context.to(() => UserInfoPage(text));
|
||||
case 3:
|
||||
// TODO: novel page
|
||||
throw UnimplementedError();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ScaffoldPage(
|
||||
content: Column(
|
||||
children: [
|
||||
buildSearchBar(),
|
||||
const SizedBox(height: 8,),
|
||||
const Expanded(
|
||||
child: _TrendingTagsView(),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
placeholder: searchTypes[searchType].tl,
|
||||
onChanged: (s) => text = s,
|
||||
foregroundDecoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: ColorScheme.of(context)
|
||||
.outlineVariant
|
||||
.withOpacity(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(
|
||||
navigatorKey: App.rootNavigatorKey.currentState,
|
||||
builder: buildSearchOption,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
).paddingHorizontal(16),
|
||||
);
|
||||
}
|
||||
|
||||
static const searchTypes = [
|
||||
"Keyword search",
|
||||
"Artwork ID",
|
||||
"Artist ID",
|
||||
"Novel ID"
|
||||
];
|
||||
|
||||
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 {
|
||||
const _TrendingTagsView();
|
||||
|
||||
@override
|
||||
State<_TrendingTagsView> createState() => _TrendingTagsViewState();
|
||||
}
|
||||
|
||||
class _TrendingTagsViewState extends LoadingState<_TrendingTagsView, List<TrendingTag>> {
|
||||
@override
|
||||
Widget buildContent(BuildContext context, List<TrendingTag> data) {
|
||||
return MasonryGridView.builder(
|
||||
gridDelegate: const SliverSimpleGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 240,
|
||||
),
|
||||
itemCount: data.length,
|
||||
itemBuilder: (context, index) {
|
||||
return buildItem(data[index]);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildItem(TrendingTag tag) {
|
||||
final illust = tag.illust;
|
||||
|
||||
var text = tag.tag.name;
|
||||
if(tag.tag.translatedName != null) {
|
||||
text += "/${tag.tag.translatedName}";
|
||||
}
|
||||
|
||||
return LayoutBuilder(builder: (context, constrains) {
|
||||
final width = constrains.maxWidth;
|
||||
final height = illust.height * width / illust.width;
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
|
||||
child: Card(
|
||||
padding: EdgeInsets.zero,
|
||||
margin: EdgeInsets.zero,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: (){
|
||||
context.to(() => SearchResultPage(tag.tag.name));
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(illust.images.first.medium),
|
||||
fit: BoxFit.cover,
|
||||
width: width-16.0,
|
||||
height: height-16.0,
|
||||
),
|
||||
)),
|
||||
Positioned(
|
||||
bottom: -2,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: FluentTheme.of(context).micaBackgroundColor.withOpacity(0.84),
|
||||
borderRadius: BorderRadius.circular(4)
|
||||
),
|
||||
child: Text(text).paddingHorizontal(4).paddingVertical(6).paddingBottom(2),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Res<List<TrendingTag>>> loadData() {
|
||||
return Network().getHotTags();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SearchResultPage extends StatefulWidget {
|
||||
const SearchResultPage(this.keyword, {super.key});
|
||||
|
||||
final String keyword;
|
||||
|
||||
@override
|
||||
State<SearchResultPage> createState() => _SearchResultPageState();
|
||||
}
|
||||
|
||||
class _SearchResultPageState extends State<SearchResultPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const ScaffoldPage();
|
||||
}
|
||||
}
|
||||
|
15
lib/pages/settings_page.dart
Normal file
15
lib/pages/settings_page.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
|
||||
class SettingsPage extends StatefulWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
State<SettingsPage> createState() => _SettingsPageState();
|
||||
}
|
||||
|
||||
class _SettingsPageState extends State<SettingsPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Placeholder();
|
||||
}
|
||||
}
|
54
lib/pages/user_info_page.dart
Normal file
54
lib/pages/user_info_page.dart
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:pixes/components/loading.dart';
|
||||
import 'package:pixes/foundation/image_provider.dart';
|
||||
import 'package:pixes/network/network.dart';
|
||||
import 'package:pixes/network/res.dart';
|
||||
import 'package:pixes/utils/translation.dart';
|
||||
|
||||
class UserInfoPage extends StatefulWidget {
|
||||
const UserInfoPage(this.id, {super.key});
|
||||
|
||||
final String id;
|
||||
|
||||
@override
|
||||
State<UserInfoPage> createState() => _UserInfoPageState();
|
||||
}
|
||||
|
||||
class _UserInfoPageState extends LoadingState<UserInfoPage, UserDetails> {
|
||||
@override
|
||||
Widget buildContent(BuildContext context, UserDetails data) {
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(64),
|
||||
child: Image(
|
||||
image: CachedImageProvider(data.avatar),
|
||||
width: 64,
|
||||
height: 64,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(data.name, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 4),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
TextSpan(text: 'Follows: '.tl),
|
||||
TextSpan(text: '${data.totalFollowUsers}', style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
],
|
||||
),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Res<UserDetails>> loadData() {
|
||||
return Network().getUserDetails(widget.id);
|
||||
}
|
||||
|
||||
}
|
41
lib/utils/app_links.dart
Normal file
41
lib/utils/app_links.dart
Normal file
@@ -0,0 +1,41 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
import 'package:pixes/foundation/log.dart';
|
||||
import 'package:win32_registry/win32_registry.dart';
|
||||
|
||||
Future<void> _register(String scheme) async {
|
||||
String appPath = Platform.resolvedExecutable;
|
||||
|
||||
String protocolRegKey = 'Software\\Classes\\$scheme';
|
||||
RegistryValue protocolRegValue = const RegistryValue(
|
||||
'URL Protocol',
|
||||
RegistryValueType.string,
|
||||
'',
|
||||
);
|
||||
String protocolCmdRegKey = 'shell\\open\\command';
|
||||
RegistryValue protocolCmdRegValue = RegistryValue(
|
||||
'',
|
||||
RegistryValueType.string,
|
||||
'"$appPath" "%1"',
|
||||
);
|
||||
|
||||
final regKey = Registry.currentUser.createKey(protocolRegKey);
|
||||
regKey.createValue(protocolRegValue);
|
||||
regKey.createKey(protocolCmdRegKey).createValue(protocolCmdRegValue);
|
||||
}
|
||||
|
||||
bool Function(Uri uri)? onLink;
|
||||
|
||||
void handleLinks() async {
|
||||
if (App.isWindows) {
|
||||
await _register("pixiv");
|
||||
}
|
||||
AppLinks().uriLinkStream.listen((uri) {
|
||||
Log.info("App Link", uri.toString());
|
||||
if (onLink?.call(uri) == true) {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
86
lib/utils/ext.dart
Normal file
86
lib/utils/ext.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
extension ListExt<T> on List<T>{
|
||||
/// Remove all blank value and return the list.
|
||||
List<T> getNoBlankList(){
|
||||
List<T> newList = [];
|
||||
for(var value in this){
|
||||
if(value.toString() != ""){
|
||||
newList.add(value);
|
||||
}
|
||||
}
|
||||
return newList;
|
||||
}
|
||||
|
||||
T? firstWhereOrNull(bool Function(T element) test){
|
||||
for(var element in this){
|
||||
if(test(element)){
|
||||
return element;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
void addIfNotNull(T? value){
|
||||
if(value != null){
|
||||
add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension StringExt on String{
|
||||
///Remove all value that would display blank on the screen.
|
||||
String get removeAllBlank => replaceAll("\n", "").replaceAll(" ", "").replaceAll("\t", "");
|
||||
|
||||
/// convert this to a one-element list.
|
||||
List<String> toList() => [this];
|
||||
|
||||
String _nums(){
|
||||
String res = "";
|
||||
for(int i=0; i<length; i++){
|
||||
res += this[i].isNum?this[i]:"";
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
String get nums => _nums();
|
||||
|
||||
String setValueAt(String value, int index){
|
||||
return replaceRange(index, index+1, value);
|
||||
}
|
||||
|
||||
String? subStringOrNull(int start, [int? end]){
|
||||
if(start < 0 || (end != null && end > length)){
|
||||
return null;
|
||||
}
|
||||
return substring(start, end);
|
||||
}
|
||||
|
||||
String replaceLast(String from, String to) {
|
||||
if (isEmpty || from.isEmpty) {
|
||||
return this;
|
||||
}
|
||||
|
||||
final lastIndex = lastIndexOf(from);
|
||||
if (lastIndex == -1) {
|
||||
return this;
|
||||
}
|
||||
|
||||
final before = substring(0, lastIndex);
|
||||
final after = substring(lastIndex + from.length);
|
||||
return '$before$to$after';
|
||||
}
|
||||
|
||||
static bool hasMatch(String? value, String pattern) {
|
||||
return (value == null) ? false : RegExp(pattern).hasMatch(value);
|
||||
}
|
||||
|
||||
bool _isURL(){
|
||||
final regex = RegExp(
|
||||
r'^((http|https|ftp)://)?[\w-]+(\.[\w-]+)+([\w.,@?^=%&:/~+#-|]*[\w@?^=%&/~+#-])?$',
|
||||
caseSensitive: false);
|
||||
return regex.hasMatch(this);
|
||||
}
|
||||
|
||||
bool get isURL => _isURL();
|
||||
|
||||
bool get isNum => double.tryParse(this) != null;
|
||||
}
|
22
lib/utils/io.dart
Normal file
22
lib/utils/io.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'dart:io';
|
||||
|
||||
extension FSExt on FileSystemEntity {
|
||||
Future<void> deleteIfExists() async {
|
||||
if (await exists()) {
|
||||
await delete();
|
||||
}
|
||||
}
|
||||
|
||||
int get size {
|
||||
if (this is File) {
|
||||
return (this as File).lengthSync();
|
||||
} else if(this is Directory){
|
||||
var size = 0;
|
||||
for(var file in (this as Directory).listSync()){
|
||||
size += file.size;
|
||||
}
|
||||
return size;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
26
lib/utils/mouse_listener.dart
Normal file
26
lib/utils/mouse_listener.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import '../foundation/app.dart';
|
||||
|
||||
void mouseSideButtonCallback(GlobalKey<NavigatorState> key){
|
||||
if(App.rootNavigatorKey.currentState?.canPop() ?? false) {
|
||||
App.rootNavigatorKey.currentState?.pop();
|
||||
return;
|
||||
}
|
||||
if(key.currentState?.canPop() ?? false){
|
||||
key.currentState?.pop();
|
||||
}
|
||||
}
|
||||
|
||||
///监听鼠标侧键, 若为下键, 则调用返回
|
||||
void listenMouseSideButtonToBack(GlobalKey<NavigatorState> key) async{
|
||||
if(!App.isWindows){
|
||||
return;
|
||||
}
|
||||
const channel = EventChannel("pixes/mouse");
|
||||
await for(var res in channel.receiveBroadcastStream()){
|
||||
if(res == 0){
|
||||
mouseSideButtonCallback(key);
|
||||
}
|
||||
}
|
||||
}
|
14
lib/utils/translation.dart
Normal file
14
lib/utils/translation.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
|
||||
extension Translation on String {
|
||||
String get tl {
|
||||
var locale = App.locale;
|
||||
return translation["${locale.languageCode}_${locale.countryCode}"]?[this] ??
|
||||
this;
|
||||
}
|
||||
|
||||
static const translation = <String, Map<String, String>>{
|
||||
"zh_CN": {},
|
||||
"zh_TW": {},
|
||||
};
|
||||
}
|
Reference in New Issue
Block a user