mirror of
https://github.com/wgh136/pixes.git
synced 2025-09-27 12:57:24 +00:00
Initial commit
This commit is contained in:
321
lib/components/animated_image.dart
Normal file
321
lib/components/animated_image.dart
Normal file
@@ -0,0 +1,321 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
class AnimatedImage extends StatefulWidget {
|
||||
/// show animation when loading is complete.
|
||||
AnimatedImage({
|
||||
required ImageProvider image,
|
||||
super.key,
|
||||
double scale = 1.0,
|
||||
this.semanticLabel,
|
||||
this.excludeFromSemantics = false,
|
||||
this.width,
|
||||
this.height,
|
||||
this.color,
|
||||
this.opacity,
|
||||
this.colorBlendMode,
|
||||
this.fit,
|
||||
this.alignment = Alignment.center,
|
||||
this.repeat = ImageRepeat.noRepeat,
|
||||
this.centerSlice,
|
||||
this.matchTextDirection = false,
|
||||
this.gaplessPlayback = false,
|
||||
this.filterQuality = FilterQuality.low,
|
||||
this.isAntiAlias = false,
|
||||
Map<String, String>? headers,
|
||||
int? cacheWidth,
|
||||
int? cacheHeight,
|
||||
}
|
||||
): image = ResizeImage.resizeIfNeeded(cacheWidth, cacheHeight, image),
|
||||
assert(cacheWidth == null || cacheWidth > 0),
|
||||
assert(cacheHeight == null || cacheHeight > 0);
|
||||
|
||||
final ImageProvider image;
|
||||
|
||||
final String? semanticLabel;
|
||||
|
||||
final bool excludeFromSemantics;
|
||||
|
||||
final double? width;
|
||||
|
||||
final double? height;
|
||||
|
||||
final bool gaplessPlayback;
|
||||
|
||||
final bool matchTextDirection;
|
||||
|
||||
final Rect? centerSlice;
|
||||
|
||||
final ImageRepeat repeat;
|
||||
|
||||
final AlignmentGeometry alignment;
|
||||
|
||||
final BoxFit? fit;
|
||||
|
||||
final BlendMode? colorBlendMode;
|
||||
|
||||
final FilterQuality filterQuality;
|
||||
|
||||
final Animation<double>? opacity;
|
||||
|
||||
final Color? color;
|
||||
|
||||
final bool isAntiAlias;
|
||||
|
||||
static void clear() => _AnimatedImageState.clear();
|
||||
|
||||
@override
|
||||
State<AnimatedImage> createState() => _AnimatedImageState();
|
||||
}
|
||||
|
||||
class _AnimatedImageState extends State<AnimatedImage> with WidgetsBindingObserver {
|
||||
ImageStream? _imageStream;
|
||||
ImageInfo? _imageInfo;
|
||||
ImageChunkEvent? _loadingProgress;
|
||||
bool _isListeningToStream = false;
|
||||
late bool _invertColors;
|
||||
int? _frameNumber;
|
||||
bool _wasSynchronouslyLoaded = false;
|
||||
late DisposableBuildContext<State<AnimatedImage>> _scrollAwareContext;
|
||||
Object? _lastException;
|
||||
ImageStreamCompleterHandle? _completerHandle;
|
||||
|
||||
static final Map<int, Size> _cache = {};
|
||||
|
||||
static clear() => _cache.clear();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_scrollAwareContext = DisposableBuildContext<State<AnimatedImage>>(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
assert(_imageStream != null);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_stopListeningToStream();
|
||||
_completerHandle?.dispose();
|
||||
_scrollAwareContext.dispose();
|
||||
_replaceImage(info: null);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_updateInvertColors();
|
||||
_resolveImage();
|
||||
|
||||
if (TickerMode.of(context)) {
|
||||
_listenToStream();
|
||||
} else {
|
||||
_stopListeningToStream(keepStreamAlive: true);
|
||||
}
|
||||
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.image != oldWidget.image) {
|
||||
_resolveImage();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAccessibilityFeatures() {
|
||||
super.didChangeAccessibilityFeatures();
|
||||
setState(() {
|
||||
_updateInvertColors();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void reassemble() {
|
||||
_resolveImage(); // in case the image cache was flushed
|
||||
super.reassemble();
|
||||
}
|
||||
|
||||
void _updateInvertColors() {
|
||||
_invertColors = MediaQuery.maybeInvertColorsOf(context)
|
||||
?? SemanticsBinding.instance.accessibilityFeatures.invertColors;
|
||||
}
|
||||
|
||||
void _resolveImage() {
|
||||
final ScrollAwareImageProvider provider = ScrollAwareImageProvider<Object>(
|
||||
context: _scrollAwareContext,
|
||||
imageProvider: widget.image,
|
||||
);
|
||||
final ImageStream newStream =
|
||||
provider.resolve(createLocalImageConfiguration(
|
||||
context,
|
||||
size: widget.width != null && widget.height != null ? Size(widget.width!, widget.height!) : null,
|
||||
));
|
||||
_updateSourceStream(newStream);
|
||||
}
|
||||
|
||||
ImageStreamListener? _imageStreamListener;
|
||||
ImageStreamListener _getListener({bool recreateListener = false}) {
|
||||
if(_imageStreamListener == null || recreateListener) {
|
||||
_lastException = null;
|
||||
_imageStreamListener = ImageStreamListener(
|
||||
_handleImageFrame,
|
||||
onChunk: _handleImageChunk,
|
||||
onError: (Object error, StackTrace? stackTrace) {
|
||||
setState(() {
|
||||
_lastException = error;
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
return _imageStreamListener!;
|
||||
}
|
||||
|
||||
void _handleImageFrame(ImageInfo imageInfo, bool synchronousCall) {
|
||||
setState(() {
|
||||
_replaceImage(info: imageInfo);
|
||||
_loadingProgress = null;
|
||||
_lastException = null;
|
||||
_frameNumber = _frameNumber == null ? 0 : _frameNumber! + 1;
|
||||
_wasSynchronouslyLoaded = _wasSynchronouslyLoaded | synchronousCall;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleImageChunk(ImageChunkEvent event) {
|
||||
setState(() {
|
||||
_loadingProgress = event;
|
||||
_lastException = null;
|
||||
});
|
||||
}
|
||||
|
||||
void _replaceImage({required ImageInfo? info}) {
|
||||
final ImageInfo? oldImageInfo = _imageInfo;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) => oldImageInfo?.dispose());
|
||||
_imageInfo = info;
|
||||
}
|
||||
|
||||
// Updates _imageStream to newStream, and moves the stream listener
|
||||
// registration from the old stream to the new stream (if a listener was
|
||||
// registered).
|
||||
void _updateSourceStream(ImageStream newStream) {
|
||||
if (_imageStream?.key == newStream.key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isListeningToStream) {
|
||||
_imageStream!.removeListener(_getListener());
|
||||
}
|
||||
|
||||
if (!widget.gaplessPlayback) {
|
||||
setState(() { _replaceImage(info: null); });
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loadingProgress = null;
|
||||
_frameNumber = null;
|
||||
_wasSynchronouslyLoaded = false;
|
||||
});
|
||||
|
||||
_imageStream = newStream;
|
||||
if (_isListeningToStream) {
|
||||
_imageStream!.addListener(_getListener());
|
||||
}
|
||||
}
|
||||
|
||||
void _listenToStream() {
|
||||
if (_isListeningToStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
_imageStream!.addListener(_getListener());
|
||||
_completerHandle?.dispose();
|
||||
_completerHandle = null;
|
||||
|
||||
_isListeningToStream = true;
|
||||
}
|
||||
|
||||
/// Stops listening to the image stream, if this state object has attached a
|
||||
/// listener.
|
||||
///
|
||||
/// If the listener from this state is the last listener on the stream, the
|
||||
/// stream will be disposed. To keep the stream alive, set `keepStreamAlive`
|
||||
/// to true, which create [ImageStreamCompleterHandle] to keep the completer
|
||||
/// alive and is compatible with the [TickerMode] being off.
|
||||
void _stopListeningToStream({bool keepStreamAlive = false}) {
|
||||
if (!_isListeningToStream) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (keepStreamAlive && _completerHandle == null && _imageStream?.completer != null) {
|
||||
_completerHandle = _imageStream!.completer!.keepAlive();
|
||||
}
|
||||
|
||||
_imageStream!.removeListener(_getListener());
|
||||
_isListeningToStream = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget result;
|
||||
|
||||
if(_imageInfo != null){
|
||||
// build image
|
||||
result = RawImage(
|
||||
// Do not clone the image, because RawImage is a stateless wrapper.
|
||||
// The image will be disposed by this state object when it is not needed
|
||||
// anymore, such as when it is unmounted or when the image stream pushes
|
||||
// a new image.
|
||||
image: _imageInfo?.image,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
debugImageLabel: _imageInfo?.debugLabel,
|
||||
scale: _imageInfo?.scale ?? 1.0,
|
||||
color: widget.color,
|
||||
opacity: widget.opacity,
|
||||
colorBlendMode: widget.colorBlendMode,
|
||||
alignment: widget.alignment,
|
||||
repeat: widget.repeat,
|
||||
centerSlice: widget.centerSlice,
|
||||
matchTextDirection: widget.matchTextDirection,
|
||||
invertColors: _invertColors,
|
||||
isAntiAlias: widget.isAntiAlias,
|
||||
filterQuality: widget.filterQuality,
|
||||
fit: widget.fit,
|
||||
);
|
||||
} else if (_lastException != null) {
|
||||
result = const Center(
|
||||
child: Icon(FluentIcons.error),
|
||||
);
|
||||
|
||||
if (!widget.excludeFromSemantics) {
|
||||
result = Semantics(
|
||||
container: widget.semanticLabel != null,
|
||||
image: true,
|
||||
label: widget.semanticLabel ?? '',
|
||||
child: result,
|
||||
);
|
||||
}
|
||||
} else{
|
||||
result = const Center();
|
||||
}
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
reverseDuration: const Duration(milliseconds: 200),
|
||||
child: result,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void debugFillProperties(DiagnosticPropertiesBuilder description) {
|
||||
super.debugFillProperties(description);
|
||||
description.add(DiagnosticsProperty<ImageStream>('stream', _imageStream));
|
||||
description.add(DiagnosticsProperty<ImageInfo>('pixels', _imageInfo));
|
||||
description.add(DiagnosticsProperty<ImageChunkEvent>('loadingProgress', _loadingProgress));
|
||||
description.add(DiagnosticsProperty<int>('frameNumber', _frameNumber));
|
||||
description.add(DiagnosticsProperty<bool>('wasSynchronouslyLoaded', _wasSynchronouslyLoaded));
|
||||
}
|
||||
}
|
38
lib/components/color_scheme.dart
Normal file
38
lib/components/color_scheme.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class ColorScheme extends InheritedWidget{
|
||||
final Brightness brightness;
|
||||
|
||||
const ColorScheme({super.key, required this.brightness, required super.child});
|
||||
|
||||
static ColorScheme of(BuildContext context){
|
||||
return context.dependOnInheritedWidgetOfExactType<ColorScheme>()!;
|
||||
}
|
||||
|
||||
bool get _light => brightness == Brightness.light;
|
||||
|
||||
Color get primary => _light ? const Color(0xff00538a) : const Color(0xff9ccaff);
|
||||
|
||||
Color get primaryContainer => _light ? const Color(0xff5fbdff) : const Color(0xff0079c5);
|
||||
|
||||
Color get secondary => _light ? const Color(0xff426182) : const Color(0xffaac9ef);
|
||||
|
||||
Color get secondaryContainer => _light ? const Color(0xffc1dcff) : const Color(0xff1f3f5f);
|
||||
|
||||
Color get tertiary => _light ? const Color(0xff743192) : const Color(0xffebb2ff);
|
||||
|
||||
Color get tertiaryContainer => _light ? const Color(0xffcf9ae8) : const Color(0xff9c58ba);
|
||||
|
||||
Color get outline => _light ? const Color(0xff707883) : const Color(0xff89919d);
|
||||
|
||||
Color get outlineVariant => _light ? const Color(0xffbfc7d3) : const Color(0xff404752);
|
||||
|
||||
Color get errorColor => _light ? const Color(0xffff3131) : const Color(0xfff86a6a);
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(covariant InheritedWidget oldWidget) {
|
||||
return oldWidget is!ColorScheme || brightness != oldWidget.brightness;
|
||||
}
|
||||
}
|
47
lib/components/illust_widget.dart
Normal file
47
lib/components/illust_widget.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:pixes/components/animated_image.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
import 'package:pixes/foundation/image_provider.dart';
|
||||
import 'package:pixes/network/models.dart';
|
||||
|
||||
import '../pages/illust_page.dart';
|
||||
|
||||
class IllustWidget extends StatelessWidget {
|
||||
const IllustWidget(this.illust, {super.key});
|
||||
|
||||
final Illust illust;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(builder: (context, constrains) {
|
||||
final width = constrains.maxWidth;
|
||||
final height = illust.height * width / illust.width;
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
|
||||
child: Card(
|
||||
padding: EdgeInsets.zero,
|
||||
margin: EdgeInsets.zero,
|
||||
child: MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: (){
|
||||
context.to(() => IllustPage(illust));
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
child: AnimatedImage(
|
||||
image: CachedImageProvider(illust.images.first.medium),
|
||||
fit: BoxFit.cover,
|
||||
width: width-16.0,
|
||||
height: height-16.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
106
lib/components/loading.dart
Normal file
106
lib/components/loading.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
import 'package:pixes/network/res.dart';
|
||||
|
||||
abstract class LoadingState<T extends StatefulWidget, S extends Object> extends State<T>{
|
||||
bool isLoading = true;
|
||||
|
||||
S? data;
|
||||
|
||||
String? error;
|
||||
|
||||
Future<Res<S>> loadData();
|
||||
|
||||
Widget buildContent(BuildContext context, S data);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if(isLoading){
|
||||
loadData().then((value) {
|
||||
if(value.success) {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
data = value.data;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
isLoading = false;
|
||||
error = value.errorMessage!;
|
||||
});
|
||||
}
|
||||
});
|
||||
return const Center(
|
||||
child: ProgressRing(),
|
||||
);
|
||||
} else if (error != null){
|
||||
return Center(
|
||||
child: Text(error!),
|
||||
);
|
||||
} else {
|
||||
return buildContent(context, data!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class MultiPageLoadingState<T extends StatefulWidget, S extends Object> extends State<T>{
|
||||
bool _isFirstLoading = true;
|
||||
|
||||
bool _isLoading = false;
|
||||
|
||||
List<S>? _data;
|
||||
|
||||
String? _error;
|
||||
|
||||
int _page = 1;
|
||||
|
||||
Future<Res<List<S>>> loadData(int page);
|
||||
|
||||
Widget buildContent(BuildContext context, final List<S> data);
|
||||
|
||||
bool get isLoading => _isLoading || _isFirstLoading;
|
||||
|
||||
void nextPage() {
|
||||
if(_isLoading) return;
|
||||
_isLoading = true;
|
||||
loadData(_page).then((value) {
|
||||
_isLoading = false;
|
||||
if(value.success) {
|
||||
_page++;
|
||||
setState(() {
|
||||
_data!.addAll(value.data);
|
||||
});
|
||||
} else {
|
||||
context.showToast(message: "Network Error");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if(_isFirstLoading){
|
||||
loadData(_page).then((value) {
|
||||
if(value.success) {
|
||||
_page++;
|
||||
setState(() {
|
||||
_isFirstLoading = false;
|
||||
_data = value.data;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_isFirstLoading = false;
|
||||
_error = value.errorMessage!;
|
||||
});
|
||||
}
|
||||
});
|
||||
return const Center(
|
||||
child: ProgressRing(),
|
||||
);
|
||||
} else if (_error != null){
|
||||
return Center(
|
||||
child: Text(_error!),
|
||||
);
|
||||
} else {
|
||||
return buildContent(context, _data!);
|
||||
}
|
||||
}
|
||||
}
|
3
lib/components/md.dart
Normal file
3
lib/components/md.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
typedef MdIcons = Icons;
|
103
lib/components/message.dart
Normal file
103
lib/components/message.dart
Normal file
@@ -0,0 +1,103 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
|
||||
void showToast(BuildContext context, {required String message, IconData? icon}) {
|
||||
var newEntry = OverlayEntry(
|
||||
builder: (context) => ToastOverlay(message: message, icon: icon));
|
||||
|
||||
OverlayWidget.of(context)?.addOverlay(newEntry);
|
||||
|
||||
Timer(const Duration(seconds: 2), () => OverlayWidget.of(context)?.remove(newEntry));
|
||||
}
|
||||
|
||||
class ToastOverlay extends StatelessWidget {
|
||||
const ToastOverlay({required this.message, this.icon, super.key});
|
||||
|
||||
final String message;
|
||||
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
bottom: 24 + MediaQuery.of(context).viewInsets.bottom,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: PhysicalModel(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
elevation: 1,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) Icon(icon),
|
||||
if (icon != null)
|
||||
const SizedBox(
|
||||
width: 8,
|
||||
),
|
||||
Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w500),
|
||||
maxLines: 3,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OverlayWidget extends StatefulWidget {
|
||||
const OverlayWidget(this.child, {super.key});
|
||||
|
||||
final Widget child;
|
||||
|
||||
static OverlayWidgetState? of(BuildContext context) {
|
||||
return LookupBoundary.findAncestorStateOfType<OverlayWidgetState>(context);
|
||||
}
|
||||
|
||||
@override
|
||||
State<OverlayWidget> createState() => OverlayWidgetState();
|
||||
}
|
||||
|
||||
class OverlayWidgetState extends State<OverlayWidget> {
|
||||
var overlayKey = GlobalKey<OverlayState>();
|
||||
|
||||
var entries = <OverlayEntry>[];
|
||||
|
||||
void addOverlay(OverlayEntry entry) {
|
||||
if (overlayKey.currentState != null) {
|
||||
overlayKey.currentState!.insert(entry);
|
||||
entries.add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
void remove(OverlayEntry entry) {
|
||||
if (entries.remove(entry)) {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
void removeAll() {
|
||||
for (var entry in entries) {
|
||||
entry.remove();
|
||||
}
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Overlay(
|
||||
key: overlayKey,
|
||||
initialEntries: [OverlayEntry(builder: (context) => widget.child)],
|
||||
);
|
||||
}
|
||||
}
|
300
lib/components/page_route.dart
Normal file
300
lib/components/page_route.dart
Normal file
@@ -0,0 +1,300 @@
|
||||
import 'dart:math';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
|
||||
const double _kBackGestureWidth = 20.0;
|
||||
const int _kMaxDroppedSwipePageForwardAnimationTime = 800;
|
||||
const int _kMaxPageBackAnimationTime = 300;
|
||||
const double _kMinFlingVelocity = 1.0;
|
||||
|
||||
class AppPageRoute<T> extends PageRoute<T> with _AppRouteTransitionMixin {
|
||||
/// Construct a MaterialPageRoute whose contents are defined by [builder].
|
||||
AppPageRoute({
|
||||
required this.builder,
|
||||
super.settings,
|
||||
this.maintainState = true,
|
||||
super.fullscreenDialog,
|
||||
super.allowSnapshotting = true,
|
||||
super.barrierDismissible = false,
|
||||
this.enableIOSGesture = true,
|
||||
this.preventRebuild = true,
|
||||
}) {
|
||||
assert(opaque);
|
||||
}
|
||||
|
||||
/// Builds the primary contents of the route.
|
||||
final WidgetBuilder builder;
|
||||
|
||||
@override
|
||||
Widget buildContent(BuildContext context) {
|
||||
return builder(context);
|
||||
}
|
||||
|
||||
@override
|
||||
final bool maintainState;
|
||||
|
||||
@override
|
||||
String get debugLabel => '${super.debugLabel}(${settings.name})';
|
||||
|
||||
@override
|
||||
final bool enableIOSGesture;
|
||||
|
||||
@override
|
||||
final bool preventRebuild;
|
||||
|
||||
static void updateBackButton() {
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
StateController.findOrNull(tag: "back_button")?.update();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
mixin _AppRouteTransitionMixin<T> on PageRoute<T> {
|
||||
/// Builds the primary contents of the route.
|
||||
@protected
|
||||
Widget buildContent(BuildContext context);
|
||||
|
||||
@override
|
||||
Duration get transitionDuration => const Duration(milliseconds: 300);
|
||||
|
||||
@override
|
||||
Color? get barrierColor => null;
|
||||
|
||||
@override
|
||||
String? get barrierLabel => null;
|
||||
|
||||
@override
|
||||
bool canTransitionTo(TransitionRoute<dynamic> nextRoute) {
|
||||
// Don't perform outgoing animation if the next route is a fullscreen dialog.
|
||||
return nextRoute is PageRoute && !nextRoute.fullscreenDialog;
|
||||
}
|
||||
|
||||
bool get enableIOSGesture;
|
||||
|
||||
bool get preventRebuild;
|
||||
|
||||
Widget? _child;
|
||||
|
||||
@override
|
||||
Widget buildPage(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
) {
|
||||
Widget result;
|
||||
|
||||
if (preventRebuild) {
|
||||
result = _child ?? (_child = buildContent(context));
|
||||
} else {
|
||||
result = buildContent(context);
|
||||
}
|
||||
|
||||
return Semantics(
|
||||
scopesRoute: true,
|
||||
explicitChildNodes: true,
|
||||
child: result,
|
||||
);
|
||||
}
|
||||
|
||||
static bool _isPopGestureEnabled<T>(PageRoute<T> route) {
|
||||
if (route.isFirst ||
|
||||
route.willHandlePopInternally ||
|
||||
route.popDisposition == RoutePopDisposition.doNotPop ||
|
||||
route.fullscreenDialog ||
|
||||
route.animation!.status != AnimationStatus.completed ||
|
||||
route.secondaryAnimation!.status != AnimationStatus.dismissed ||
|
||||
route.navigator!.userGestureInProgress) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildTransitions(BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation, Widget child) {
|
||||
return DrillInPageTransition(
|
||||
animation: CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: FluentTheme.of(context).animationCurve,
|
||||
),
|
||||
child: enableIOSGesture
|
||||
? IOSBackGestureDetector(
|
||||
gestureWidth: _kBackGestureWidth,
|
||||
enabledCallback: () => _isPopGestureEnabled<T>(this),
|
||||
onStartPopGesture: () => _startPopGesture(this),
|
||||
child: child)
|
||||
: child,
|
||||
);
|
||||
}
|
||||
|
||||
IOSBackGestureController _startPopGesture(PageRoute<T> route) {
|
||||
return IOSBackGestureController(route.controller!, route.navigator!);
|
||||
}
|
||||
}
|
||||
|
||||
class IOSBackGestureController {
|
||||
final AnimationController controller;
|
||||
|
||||
final NavigatorState navigator;
|
||||
|
||||
IOSBackGestureController(this.controller, this.navigator) {
|
||||
navigator.didStartUserGesture();
|
||||
}
|
||||
|
||||
void dragEnd(double velocity) {
|
||||
const Curve animationCurve = Curves.fastLinearToSlowEaseIn;
|
||||
final bool animateForward;
|
||||
|
||||
if (velocity.abs() >= _kMinFlingVelocity) {
|
||||
animateForward = velocity <= 0;
|
||||
} else {
|
||||
animateForward = controller.value > 0.5;
|
||||
}
|
||||
|
||||
if (animateForward) {
|
||||
final droppedPageForwardAnimationTime = min(
|
||||
lerpDouble(
|
||||
_kMaxDroppedSwipePageForwardAnimationTime, 0, controller.value)!
|
||||
.floor(),
|
||||
_kMaxPageBackAnimationTime,
|
||||
);
|
||||
controller.animateTo(1.0,
|
||||
duration: Duration(milliseconds: droppedPageForwardAnimationTime),
|
||||
curve: animationCurve);
|
||||
} else {
|
||||
navigator.pop();
|
||||
if (controller.isAnimating) {
|
||||
final droppedPageBackAnimationTime = lerpDouble(
|
||||
0, _kMaxDroppedSwipePageForwardAnimationTime, controller.value)!
|
||||
.floor();
|
||||
controller.animateBack(0.0,
|
||||
duration: Duration(milliseconds: droppedPageBackAnimationTime),
|
||||
curve: animationCurve);
|
||||
}
|
||||
}
|
||||
|
||||
if (controller.isAnimating) {
|
||||
late AnimationStatusListener animationStatusCallback;
|
||||
animationStatusCallback = (status) {
|
||||
navigator.didStopUserGesture();
|
||||
controller.removeStatusListener(animationStatusCallback);
|
||||
};
|
||||
controller.addStatusListener(animationStatusCallback);
|
||||
} else {
|
||||
navigator.didStopUserGesture();
|
||||
}
|
||||
}
|
||||
|
||||
void dragUpdate(double delta) {
|
||||
controller.value -= delta;
|
||||
}
|
||||
}
|
||||
|
||||
class IOSBackGestureDetector extends StatefulWidget {
|
||||
const IOSBackGestureDetector(
|
||||
{required this.enabledCallback,
|
||||
required this.child,
|
||||
required this.gestureWidth,
|
||||
required this.onStartPopGesture,
|
||||
super.key});
|
||||
|
||||
final double gestureWidth;
|
||||
|
||||
final bool Function() enabledCallback;
|
||||
|
||||
final IOSBackGestureController Function() onStartPopGesture;
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
State<IOSBackGestureDetector> createState() => _IOSBackGestureDetectorState();
|
||||
}
|
||||
|
||||
class _IOSBackGestureDetectorState extends State<IOSBackGestureDetector> {
|
||||
IOSBackGestureController? _backGestureController;
|
||||
|
||||
late HorizontalDragGestureRecognizer _recognizer;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_recognizer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_recognizer = HorizontalDragGestureRecognizer(debugOwner: this)
|
||||
..onStart = _handleDragStart
|
||||
..onUpdate = _handleDragUpdate
|
||||
..onEnd = _handleDragEnd
|
||||
..onCancel = _handleDragCancel;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var dragAreaWidth = Directionality.of(context) == TextDirection.ltr
|
||||
? MediaQuery.of(context).padding.left
|
||||
: MediaQuery.of(context).padding.right;
|
||||
dragAreaWidth = max(dragAreaWidth, widget.gestureWidth);
|
||||
return Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: <Widget>[
|
||||
widget.child,
|
||||
Positioned(
|
||||
width: dragAreaWidth,
|
||||
top: 0.0,
|
||||
bottom: 0.0,
|
||||
left: 0,
|
||||
child: Listener(
|
||||
onPointerDown: _handlePointerDown,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePointerDown(PointerDownEvent event) {
|
||||
if (widget.enabledCallback()) _recognizer.addPointer(event);
|
||||
}
|
||||
|
||||
void _handleDragCancel() {
|
||||
assert(mounted);
|
||||
_backGestureController?.dragEnd(0.0);
|
||||
_backGestureController = null;
|
||||
}
|
||||
|
||||
double _convertToLogical(double value) {
|
||||
switch (Directionality.of(context)) {
|
||||
case TextDirection.rtl:
|
||||
return -value;
|
||||
case TextDirection.ltr:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
void _handleDragEnd(DragEndDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController != null);
|
||||
_backGestureController!.dragEnd(_convertToLogical(
|
||||
details.velocity.pixelsPerSecond.dx / context.size!.width));
|
||||
_backGestureController = null;
|
||||
}
|
||||
|
||||
void _handleDragStart(DragStartDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController == null);
|
||||
_backGestureController = widget.onStartPopGesture();
|
||||
}
|
||||
|
||||
void _handleDragUpdate(DragUpdateDetails details) {
|
||||
assert(mounted);
|
||||
assert(_backGestureController != null);
|
||||
_backGestureController!.dragUpdate(
|
||||
_convertToLogical(details.primaryDelta! / context.size!.width));
|
||||
}
|
||||
}
|
72
lib/components/segmented_button.dart
Normal file
72
lib/components/segmented_button.dart
Normal file
@@ -0,0 +1,72 @@
|
||||
import 'package:fluent_ui/fluent_ui.dart';
|
||||
import 'package:pixes/foundation/app.dart';
|
||||
|
||||
import 'color_scheme.dart';
|
||||
|
||||
class SegmentedButton<T> extends StatelessWidget {
|
||||
const SegmentedButton(
|
||||
{required this.options,
|
||||
required this.value,
|
||||
required this.onPressed,
|
||||
super.key});
|
||||
|
||||
final List<SegmentedButtonOption<T>> options;
|
||||
|
||||
final T value;
|
||||
|
||||
final void Function(T key) onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Card(
|
||||
padding: EdgeInsets.zero,
|
||||
child: SizedBox(
|
||||
height: 36,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: options.map((e) => buildButton(e)).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildButton(SegmentedButtonOption<T> e) {
|
||||
bool active = value == e.key;
|
||||
return HoverButton(
|
||||
cursor: active ? MouseCursor.defer : SystemMouseCursors.click,
|
||||
onPressed: () => onPressed(e.key),
|
||||
builder: (context, states) {
|
||||
var textColor = active ? null : ColorScheme.of(context).outline;
|
||||
var backgroundColor = active ? null : ButtonState.resolveWith((states) {
|
||||
return ButtonThemeData.buttonColor(context, states);
|
||||
}).resolve(states);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
border: e != options.last
|
||||
? Border(
|
||||
right: BorderSide(
|
||||
width: 0.6,
|
||||
color: ColorScheme.of(context).outlineVariant))
|
||||
: null),
|
||||
child: Center(
|
||||
child: Text(e.text,
|
||||
style: TextStyle(
|
||||
color: textColor, fontWeight: FontWeight.w500))
|
||||
.paddingHorizontal(12),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class SegmentedButtonOption<T> {
|
||||
final T key;
|
||||
final String text;
|
||||
|
||||
const SegmentedButtonOption(this.key, this.text);
|
||||
}
|
Reference in New Issue
Block a user