Initial commit

This commit is contained in:
wgh19
2024-05-13 09:36:23 +08:00
commit b095643cbc
160 changed files with 9956 additions and 0 deletions

23
lib/appdata.dart Normal file
View 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();

View 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));
}
}

View 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;
}
}

View 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
View 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
View File

@@ -0,0 +1,3 @@
import 'package:flutter/material.dart';
typedef MdIcons = Icons;

103
lib/components/message.dart Normal file
View 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)],
);
}
}

View 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));
}
}

View 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
View 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();

View 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();
}
}

View 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
View 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;
}
}

View 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
View 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"));
}

View 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;
}

View 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
View 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
View 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;
}
}

View 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
View 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
View 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
View 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
View 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;
}
}

View 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"),
);
}
}

View File

499
lib/pages/illust_page.dart Normal file
View 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
View 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
View 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
View 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
View 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();
}
}

View 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
View 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();
}
}

View 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();
}
}

View 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
View 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
View 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
View 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;
}
}

View 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);
}
}
}

View 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": {},
};
}