This commit is contained in:
2026-03-07 17:24:59 +08:00
parent 4418ebecac
commit b0ec8ab4bd
417 changed files with 42546 additions and 2 deletions

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
class BorderText extends StatelessWidget {
final String text;
final TextAlign textAlign;
final Color color;
final double fontSize;
final double strokeWidth;
const BorderText(
this.text, {
this.textAlign = TextAlign.left,
this.color = Colors.white,
this.fontSize = 16,
this.strokeWidth = 2.0,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Text(
text,
softWrap: false,
textAlign: textAlign,
style: TextStyle(
fontSize: fontSize,
foreground: Paint()
..style = PaintingStyle.stroke
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..color = getBorderColor(color),
),
),
Text(
text,
softWrap: false,
textAlign: textAlign,
style: TextStyle(
fontSize: fontSize,
color: color,
),
),
],
);
}
Color getBorderColor(Color color) {
var brightness =
((color.red * 299) + (color.green * 587) + (color.blue * 114)) / 1000;
return brightness > 70 ? Colors.black : Colors.white;
}
}

View File

@@ -0,0 +1,328 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/dialog_utils.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/models/comment/comment_item.dart';
import 'package:flutter_dmzj/requests/comment_request.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/user_photo.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'dart:ui' as ui;
import 'package:remixicon/remixicon.dart';
// ignore: must_be_immutable
class CommentItemWidget extends StatelessWidget {
final CommentItem item;
CommentItemWidget(this.item, {Key? key}) : super(key: key);
var expand = false.obs;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
onTap(item);
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
InkWell(
onTap: () {
AppNavigator.toUserCenter(item.userId);
},
child: UserPhoto(
url: item.photo,
),
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
Expanded(
child: Text(
item.nickname,
maxLines: 1,
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
),
overflow: TextOverflow.ellipsis,
),
),
// const Text(
// "-",
// style: TextStyle(color: Colors.grey),
// )
],
),
AppStyle.vGap12,
item.parents.isNotEmpty
? Obx(
() => expand.value
? createMasterCommentAll(item.parents)
: createMasterComment(item),
)
: Container(),
Text(
item.content,
style: Get.theme.textTheme.bodyMedium,
),
item.images.isNotEmpty
? Padding(
padding: AppStyle.edgeInsetsT12,
child: Wrap(
spacing: 4,
runSpacing: 4,
children: item.images.map<Widget>((f) {
var str = f.split(".").toList();
var fileImg = str[0];
var fileImgSuffix = str[1];
return InkWell(
onTap: () {
DialogUtils.showImageViewer(0, [
"https://images.zaimanhua.com/commentImg/${item.objId % 500}/$f"
]);
},
child: NetImage(
"https://images.zaimanhua.com/commentImg/${item.objId % 500}/${fileImg}_small.$fileImgSuffix",
width: 100,
height: 100,
borderRadius: 4,
),
);
}).toList(),
),
)
: const SizedBox(),
AppStyle.vGap12,
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Expanded(
child: Text(
Utils.formatTimestamp(item.createTime),
style:
const TextStyle(color: Colors.grey, fontSize: 12),
),
),
Obx(
() => GestureDetector(
onTap: () {
likeComment(item);
},
child: Row(
children: [
Icon(
Remix.thumb_up_fill,
size: 16,
color: Theme.of(context).colorScheme.secondary,
),
Visibility(
visible: item.likeAmount.value > 0,
child: Padding(
padding: AppStyle.edgeInsetsL4,
child: Text(
item.likeAmount.value.toString(),
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
),
),
),
],
),
),
),
],
)
],
))
],
),
),
);
}
Widget createMasterComment(CommentItem comment) {
var list = comment.parents;
if (list.isEmpty) return const SizedBox();
List<Widget> items = [];
if (list.length > 2) {
items.add(createMsterCommentItem(list.first));
items.add(InkWell(
onTap: () {
expand.value = true;
},
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(4)),
padding: AppStyle.edgeInsetsA8,
child: Center(
child: Text(
"点击展开${list.length - 2}条评论",
style: const TextStyle(fontSize: 12, color: Colors.grey),
)),
),
));
items.add(AppStyle.vGap8);
items.add(createMsterCommentItem(list.last));
} else {
for (var item in list) {
items.add(createMsterCommentItem(item));
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: items,
);
}
Widget createMasterCommentAll(List<CommentItem> list) {
List<Widget> items = list.map<Widget>((item) {
return createMsterCommentItem(item);
}).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: items,
);
}
Widget createMsterCommentItem(CommentItem item) {
return Padding(
padding: AppStyle.edgeInsetsB8,
child: InkWell(
onTap: () {
onTap(item);
},
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(4)),
padding: AppStyle.edgeInsetsA8,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
RichText(
text: TextSpan(children: [
WidgetSpan(
alignment: ui.PlaceholderAlignment.middle,
child: InkWell(
child: Text(
item.nickname,
style:
TextStyle(color: Get.theme.colorScheme.secondary),
),
),
),
TextSpan(
text: ": ${item.content}",
style: Get.theme.textTheme.bodyMedium,
)
]),
),
item.images.isNotEmpty
? Padding(
padding: AppStyle.edgeInsetsT8,
child: Wrap(
spacing: 4,
runSpacing: 4,
children: item.images.map<Widget>((f) {
var str = f.split(".").toList();
var fileImg = str[0];
var fileImgSuffix = str[1];
return InkWell(
onTap: () {
DialogUtils.showImageViewer(0, [
"https://images.idmzj.com/commentImg/${item.objId % 500}/$f"
]);
},
child: NetImage(
"https://images.idmzj.com/commentImg/${item.objId % 500}/${fileImg}_small.$fileImgSuffix",
width: 100,
height: 100,
borderRadius: 4,
),
);
}).toList(),
),
)
: Container(),
],
),
),
),
);
}
void likeComment(CommentItem item) async {
try {
await CommentRequest().likeComment(
commentId: item.id,
objId: item.objId,
type: item.type,
);
item.likeAmount.value += 1;
} catch (e) {
SmartDialog.showToast(e.toString());
}
}
void onTap(CommentItem item) {
AppNavigator.showBottomSheet(Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
ListTile(
title: Text(item.nickname),
leading: UserPhoto(
url: item.photo,
size: 32,
showBoder: true,
),
onTap: () {
AppNavigator.toUserCenter(item.userId);
},
),
ListTile(
title: const Text("复制内容"),
leading: const Icon(Icons.content_copy),
onTap: () {
Utils.copyText(item.content);
AppNavigator.closePage();
},
),
ListTile(
title: const Text("点赞评论"),
leading: const Icon(Icons.thumb_up_outlined),
onTap: () {
AppNavigator.closePage();
likeComment(item);
},
),
ListTile(
title: const Text("回复评论"),
leading: const Icon(Icons.message_outlined),
onTap: () {
AppNavigator.closePage();
AppNavigator.toAddComment(
objId: item.objId,
type: item.type,
replyItem: item,
);
},
),
],
));
}
}

View File

@@ -0,0 +1,439 @@
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart' as physics;
import 'dart:math' as math;
/// Material header.
class MaterialHeader2 extends Header {
final Key? key;
/// See [ProgressIndicator.backgroundColor].
final Color? backgroundColor;
/// See [ProgressIndicator.color].
final Color? color;
/// See [ProgressIndicator.valueColor].
final Animation<Color?>? valueColor;
/// See [ProgressIndicator.semanticsLabel].
final String? semanticsLabel;
/// See [ProgressIndicator.semanticsLabel].
final String? semanticsValue;
/// Icon when [IndicatorResult.noMore].
final Widget? noMoreIcon;
/// Show bezier background.
final bool showBezierBackground;
/// Bezier background color.
/// See [BezierBackground.color].
final Color? bezierBackgroundColor;
/// Bezier background animation.
/// See [BezierBackground.useAnimation].
final bool bezierBackgroundAnimation;
/// Bezier background bounce.
/// See [BezierBackground.bounce].
final bool bezierBackgroundBounce;
final Widget child;
const MaterialHeader2({
this.key,
double triggerOffset = 100,
bool clamping = true,
IndicatorPosition position = IndicatorPosition.above,
Duration processedDuration = const Duration(milliseconds: 200),
physics.SpringDescription? spring,
bool springRebound = false,
SpringBuilder? readySpringBuilder,
FrictionFactor? frictionFactor,
bool safeArea = true,
double? infiniteOffset,
bool? hitOver,
bool? infiniteHitOver,
bool hapticFeedback = false,
bool triggerWhenRelease = false,
double maxOverOffset = double.infinity,
required this.child,
this.backgroundColor,
this.color,
this.valueColor,
this.semanticsLabel,
this.semanticsValue,
this.noMoreIcon,
this.showBezierBackground = false,
this.bezierBackgroundColor,
this.bezierBackgroundAnimation = false,
this.bezierBackgroundBounce = false,
}) : super(
triggerOffset: triggerOffset,
clamping: clamping,
processedDuration: processedDuration,
spring: spring,
readySpringBuilder: readySpringBuilder ??
(bezierBackgroundAnimation
? kBezierSpringBuilder
: kMaterialSpringBuilder),
springRebound: springRebound,
frictionFactor: frictionFactor ??
(showBezierBackground
? kBezierFrictionFactor
: kMaterialFrictionFactor),
horizontalFrictionFactor: frictionFactor ??
(showBezierBackground
? kBezierHorizontalFrictionFactor
: kMaterialHorizontalFrictionFactor),
safeArea: safeArea,
infiniteOffset: infiniteOffset,
hitOver: hitOver,
infiniteHitOver: infiniteHitOver,
position: position,
hapticFeedback: hapticFeedback,
triggerWhenRelease: triggerWhenRelease,
maxOverOffset: maxOverOffset,
);
@override
Widget build(BuildContext context, IndicatorState state) {
return _MaterialIndicator(
key: key,
state: state,
disappearDuration: processedDuration,
reverse: state.reverse,
backgroundColor: backgroundColor,
color: color,
valueColor: valueColor,
semanticsLabel: semanticsLabel,
semanticsValue: semanticsValue,
noMoreIcon: noMoreIcon,
showBezierBackground: showBezierBackground,
bezierBackgroundColor: bezierBackgroundColor,
bezierBackgroundAnimation: bezierBackgroundAnimation,
bezierBackgroundBounce: bezierBackgroundBounce,
child: child,
);
}
}
class MaterialFooter2 extends Footer {
final Key? key;
/// See [ProgressIndicator.backgroundColor].
final Color? backgroundColor;
/// See [ProgressIndicator.color].
final Color? color;
/// See [ProgressIndicator.valueColor].
final Animation<Color?>? valueColor;
/// See [ProgressIndicator.semanticsLabel].
final String? semanticsLabel;
/// See [ProgressIndicator.semanticsLabel].
final String? semanticsValue;
/// Icon when [IndicatorResult.noMore].
final Widget? noMoreIcon;
/// Show bezier background.
final bool showBezierBackground;
/// Bezier background color.
/// See [BezierBackground.color].
final Color? bezierBackgroundColor;
/// Bezier background animation.
/// See [BezierBackground.useAnimation].
final bool bezierBackgroundAnimation;
/// Bezier background bounce.
/// See [BezierBackground.bounce].
final bool bezierBackgroundBounce;
final Widget child;
const MaterialFooter2({
this.key,
double triggerOffset = 100,
bool clamping = true,
IndicatorPosition position = IndicatorPosition.above,
Duration processedDuration = const Duration(milliseconds: 200),
physics.SpringDescription? spring,
SpringBuilder? readySpringBuilder,
bool springRebound = false,
FrictionFactor? frictionFactor,
bool safeArea = true,
double? infiniteOffset,
bool? hitOver,
bool? infiniteHitOver,
bool hapticFeedback = false,
bool triggerWhenRelease = false,
double maxOverOffset = double.infinity,
required this.child,
this.backgroundColor,
this.color,
this.valueColor,
this.semanticsLabel,
this.semanticsValue,
this.noMoreIcon,
this.showBezierBackground = false,
this.bezierBackgroundColor,
this.bezierBackgroundAnimation = false,
this.bezierBackgroundBounce = false,
}) : super(
triggerOffset: triggerOffset,
clamping: clamping,
processedDuration: processedDuration,
spring: spring,
readySpringBuilder: readySpringBuilder ??
(bezierBackgroundAnimation
? kBezierSpringBuilder
: kMaterialSpringBuilder),
springRebound: springRebound,
frictionFactor: frictionFactor ??
(showBezierBackground
? kBezierFrictionFactor
: kMaterialFrictionFactor),
horizontalFrictionFactor: frictionFactor ??
(showBezierBackground
? kBezierHorizontalFrictionFactor
: kMaterialHorizontalFrictionFactor),
safeArea: safeArea,
infiniteOffset: infiniteOffset,
hitOver: hitOver,
infiniteHitOver: infiniteHitOver,
position: position,
hapticFeedback: hapticFeedback,
triggerWhenRelease: triggerWhenRelease,
maxOverOffset: maxOverOffset,
);
@override
Widget build(BuildContext context, IndicatorState state) {
return _MaterialIndicator(
key: key,
state: state,
disappearDuration: processedDuration,
reverse: !state.reverse,
backgroundColor: backgroundColor,
color: color,
valueColor: valueColor,
semanticsLabel: semanticsLabel,
semanticsValue: semanticsValue,
noMoreIcon: noMoreIcon,
showBezierBackground: showBezierBackground,
bezierBackgroundColor: bezierBackgroundColor,
bezierBackgroundAnimation: bezierBackgroundAnimation,
bezierBackgroundBounce: bezierBackgroundBounce,
child: child,
);
}
}
/// Material indicator.
/// Base widget for [MaterialHeader] and [MaterialFooter].
class _MaterialIndicator extends StatefulWidget {
/// Indicator properties and state.
final IndicatorState state;
/// See [ProgressIndicator.backgroundColor].
final Color? backgroundColor;
/// See [ProgressIndicator.color].
final Color? color;
/// See [ProgressIndicator.valueColor].
final Animation<Color?>? valueColor;
/// See [ProgressIndicator.semanticsLabel].
final String? semanticsLabel;
/// See [ProgressIndicator.semanticsLabel].
final String? semanticsValue;
/// Indicator disappears duration.
/// When the mode is [IndicatorMode.processed].
final Duration disappearDuration;
/// True for up and left.
/// False for down and right.
final bool reverse;
/// Icon when [IndicatorResult.noMore].
final Widget? noMoreIcon;
/// Show bezier background.
final bool showBezierBackground;
/// Bezier background color.
/// See [BezierBackground.color].
final Color? bezierBackgroundColor;
/// Bezier background animation.
/// See [BezierBackground.useAnimation].
final bool bezierBackgroundAnimation;
/// Bezier background bounce.
/// See [BezierBackground.bounce].
final bool bezierBackgroundBounce;
final Widget child;
const _MaterialIndicator({
Key? key,
required this.state,
required this.disappearDuration,
required this.reverse,
required this.child,
this.backgroundColor,
this.color,
this.valueColor,
this.semanticsLabel,
this.semanticsValue,
this.noMoreIcon,
this.showBezierBackground = false,
this.bezierBackgroundColor,
this.bezierBackgroundAnimation = false,
this.bezierBackgroundBounce = false,
}) : super(key: key);
@override
State<_MaterialIndicator> createState() => _MaterialIndicatorState();
}
/// See [ProgressIndicator] _kMinCircularProgressIndicatorSize.
const double _kCircularProgressIndicatorSize = 48;
/// Friction factor used by material.
double kMaterialFrictionFactor(double overscrollFraction) =>
0.875 * math.pow(1 - overscrollFraction, 2);
/// Friction factor used by material horizontal.
double kMaterialHorizontalFrictionFactor(double overscrollFraction) =>
1.0 * math.pow(1 - overscrollFraction, 2);
/// Spring description used by material.
physics.SpringDescription kMaterialSpringBuilder({
required IndicatorMode mode,
required double offset,
required double actualTriggerOffset,
required double velocity,
}) =>
physics.SpringDescription.withDampingRatio(
mass: 1,
stiffness: 500,
ratio: 1.1,
);
class _MaterialIndicatorState extends State<_MaterialIndicator> {
IndicatorMode get _mode => widget.state.mode;
IndicatorResult get _result => widget.state.result;
Axis get _axis => widget.state.axis;
double get _offset => widget.state.offset;
double get _actualTriggerOffset => widget.state.actualTriggerOffset;
/// Build [RefreshProgressIndicator].
Widget _buildIndicator() {
return Container(
alignment: _axis == Axis.vertical
? (widget.reverse ? Alignment.topCenter : Alignment.bottomCenter)
: (widget.reverse ? Alignment.centerLeft : Alignment.centerRight),
height: _axis == Axis.vertical ? _actualTriggerOffset : double.infinity,
width: _axis == Axis.horizontal ? _actualTriggerOffset : double.infinity,
child: Stack(
alignment: Alignment.center,
children: [
widget.child,
if (_mode == IndicatorMode.inactive &&
_result == IndicatorResult.noMore)
widget.noMoreIcon ?? const Icon(Icons.inbox_outlined),
],
),
);
}
@override
Widget build(BuildContext context) {
double offset = _offset;
if (widget.state.indicator.infiniteOffset != null &&
widget.state.indicator.position == IndicatorPosition.locator &&
(_mode != IndicatorMode.inactive ||
_result == IndicatorResult.noMore)) {
offset = _actualTriggerOffset;
}
final padding = math.max(_offset - _kCircularProgressIndicatorSize, 0) / 2;
return Stack(
clipBehavior: Clip.none,
children: [
SizedBox(
width: _axis == Axis.vertical ? double.infinity : offset,
height: _axis == Axis.horizontal ? double.infinity : offset,
),
if (widget.showBezierBackground)
Positioned(
top: _axis == Axis.vertical
? widget.reverse
? null
: 0
: 0,
left: _axis == Axis.horizontal
? widget.reverse
? null
: 0
: 0,
right: _axis == Axis.horizontal
? widget.reverse
? 0
: null
: 0,
bottom: _axis == Axis.vertical
? widget.reverse
? 0
: null
: 0,
child: BezierBackground(
state: widget.state,
color: widget.bezierBackgroundColor,
useAnimation: widget.bezierBackgroundAnimation,
bounce: widget.bezierBackgroundBounce,
reverse: widget.reverse,
),
),
Positioned(
top: _axis == Axis.vertical
? widget.reverse
? padding
: null
: 0,
bottom: _axis == Axis.vertical
? widget.reverse
? null
: padding
: 0,
left: _axis == Axis.horizontal
? widget.reverse
? padding
: null
: 0,
right: _axis == Axis.horizontal
? widget.reverse
? null
: padding
: 0,
child: Center(
child: _buildIndicator(),
),
),
],
);
}
}

View File

@@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
class KeepAliveWrapper extends StatefulWidget {
final Widget child;
const KeepAliveWrapper({Key? key, required this.child}) : super(key: key);
@override
State<KeepAliveWrapper> createState() => _KeepAliveWrapperState();
}
class _KeepAliveWrapperState extends State<KeepAliveWrapper>
with AutomaticKeepAliveClientMixin {
@override
Widget build(BuildContext context) {
super.build(context);
return widget.child;
}
@override
bool get wantKeepAlive => true;
}

20
lib/widgets/loadding.dart Normal file
View File

@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
class LoaddingWidget extends StatelessWidget {
const LoaddingWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Container(
padding: AppStyle.edgeInsetsA24,
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
),
child: const CircularProgressIndicator(),
),
);
}
}

View File

@@ -0,0 +1,69 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class LocalImage extends StatelessWidget {
final String path;
final double? width;
final double? height;
final BoxFit? fit;
final double borderRadius;
final bool progress;
const LocalImage(this.path,
{this.width,
this.height,
this.fit = BoxFit.cover,
this.borderRadius = 0,
this.progress = false,
Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
if (path.isEmpty) {
return Container(
decoration: BoxDecoration(
color: Colors.grey.withOpacity(.1),
),
child: const Icon(
Icons.image,
color: Colors.grey,
size: 24,
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: FutureBuilder(
future: File(path).readAsBytes(),
builder: (_, snap) {
if (snap.hasError) {
return Container(
decoration: BoxDecoration(
color: Colors.grey.withOpacity(.1),
),
child: const Icon(
Icons.broken_image,
color: Colors.grey,
size: 24,
),
);
}
if (!snap.hasData) {
return const Center(
child: CircularProgressIndicator(),
);
}
return Image.memory(
snap.data as Uint8List,
fit: fit,
height: height,
width: width,
);
},
),
);
}
}

137
lib/widgets/net_image.dart Normal file
View File

@@ -0,0 +1,137 @@
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
class NetImage extends StatefulWidget {
final String picUrl;
final double? width;
final double? height;
final BoxFit? fit;
final double borderRadius;
final bool progress;
const NetImage(this.picUrl,
{this.width,
this.height,
this.fit = BoxFit.cover,
this.borderRadius = 0,
this.progress = false,
Key? key})
: super(key: key);
@override
State<NetImage> createState() => _NetImageState();
}
class _NetImageState extends State<NetImage>
with SingleTickerProviderStateMixin {
late AnimationController animationController;
@override
void initState() {
animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
super.initState();
}
@override
Widget build(BuildContext context) {
var picUrl = widget.picUrl;
if (picUrl.isEmpty) {
return Container(
decoration: BoxDecoration(
color: Colors.grey.withOpacity(.1),
),
child: const Icon(
Icons.image,
color: Colors.grey,
size: 24,
),
);
}
return ClipRRect(
borderRadius: BorderRadius.circular(widget.borderRadius),
child: ExtendedImage.network(
picUrl,
fit: widget.fit,
height: widget.height,
width: widget.width,
shape: BoxShape.rectangle,
handleLoadingProgress: widget.progress,
borderRadius: BorderRadius.circular(widget.borderRadius),
headers: const {'Referer': "http://www.zaimanhua.com/"},
loadStateChanged: (e) {
if (e.extendedImageLoadState == LoadState.loading) {
animationController.reset();
final double? progress =
e.loadingProgress?.expectedTotalBytes != null
? e.loadingProgress!.cumulativeBytesLoaded /
e.loadingProgress!.expectedTotalBytes!
: null;
if (widget.progress) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(
value: progress,
),
AppStyle.vGap4,
Text(
'${((progress ?? 0.0) * 100).toInt()}%',
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 12),
),
],
),
);
}
return Container(
decoration: BoxDecoration(
color: Colors.grey.withOpacity(.1),
),
child: const Icon(
Icons.image,
color: Colors.grey,
size: 24,
),
);
}
if (e.extendedImageLoadState == LoadState.failed) {
animationController.reset();
return Container(
decoration: BoxDecoration(
color: Colors.grey.withOpacity(.1),
),
child: const Icon(
Icons.broken_image,
color: Colors.grey,
size: 24,
),
);
}
if (e.extendedImageLoadState == LoadState.completed) {
if (e.wasSynchronouslyLoaded) {
return e.completedWidget;
}
animationController.forward();
return FadeTransition(
opacity: animationController,
child: e.completedWidget,
);
}
return null;
},
),
);
}
@override
void dispose() {
animationController.dispose();
super.dispose();
}
}

View File

@@ -0,0 +1,83 @@
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/widgets/status/app_empty_widget.dart';
import 'package:flutter_dmzj/widgets/status/app_error_widget.dart';
import 'package:flutter_dmzj/widgets/status/app_loadding_widget.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:get/get.dart';
class PageGridView extends StatelessWidget {
final BasePageController pageController;
final IndexedWidgetBuilder itemBuilder;
final EdgeInsets? padding;
final bool firstRefresh;
final Function()? onLoginSuccess;
final bool showPageLoadding;
final double crossAxisSpacing, mainAxisSpacing;
final int crossAxisCount;
final bool loadMore;
const PageGridView({
required this.itemBuilder,
required this.pageController,
this.padding,
this.firstRefresh = false,
this.showPageLoadding = false,
this.onLoginSuccess,
this.crossAxisSpacing = 0.0,
this.mainAxisSpacing = 0.0,
required this.crossAxisCount,
this.loadMore = true,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Obx(
() => Stack(
children: [
EasyRefresh(
header: const MaterialHeader(),
footer: loadMore
? const MaterialFooter(
clamping: false, infiniteOffset: 70, triggerOffset: 70)
: null,
controller: pageController.easyRefreshController,
refreshOnStart: firstRefresh,
onLoad: loadMore ? pageController.loadData : null,
onRefresh: pageController.refreshData,
child: MasonryGridView.count(
padding: padding ?? EdgeInsets.zero,
controller: pageController.scrollController,
itemCount: pageController.list.length,
itemBuilder: itemBuilder,
crossAxisCount: crossAxisCount,
crossAxisSpacing: crossAxisSpacing,
mainAxisSpacing: mainAxisSpacing,
),
),
Offstage(
offstage: !pageController.pageEmpty.value,
child: AppEmptyWidget(
onRefresh: () => pageController.refreshData(),
),
),
Offstage(
offstage: !(showPageLoadding && pageController.pageLoadding.value),
child: const AppLoaddingWidget(),
),
Offstage(
offstage: !pageController.pageError.value,
child: AppErrorWidget(
errorMsg: pageController.errorMsg.value,
error: pageController.error,
onRefresh: () => pageController.refreshData(),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,97 @@
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/widgets/status/app_empty_widget.dart';
import 'package:flutter_dmzj/widgets/status/app_error_widget.dart';
import 'package:flutter_dmzj/widgets/status/app_loadding_widget.dart';
import 'package:get/get.dart';
typedef IndexedWidgetBuilder = Widget Function(BuildContext context, int index);
class PageListView extends StatelessWidget {
final BasePageController pageController;
final IndexedWidgetBuilder itemBuilder;
final IndexedWidgetBuilder? separatorBuilder;
final EdgeInsets? padding;
final bool firstRefresh;
final Function()? onLoginSuccess;
final bool showPageLoadding;
final bool loadMore;
final Widget? header;
const PageListView({
required this.itemBuilder,
required this.pageController,
this.padding,
this.firstRefresh = false,
this.showPageLoadding = false,
this.separatorBuilder,
this.onLoginSuccess,
this.loadMore = true,
this.header,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Obx(
() => Stack(
children: [
EasyRefresh(
header: const MaterialHeader(),
footer: loadMore
? const MaterialFooter(
clamping: false, infiniteOffset: 70, triggerOffset: 70)
: null,
controller: pageController.easyRefreshController,
refreshOnStart: firstRefresh,
onLoad: loadMore ? pageController.loadData : null,
onRefresh: pageController.refreshData,
child: ListView.separated(
padding: padding ?? EdgeInsets.zero,
controller: pageController.scrollController,
itemCount: header == null
? pageController.list.length
: pageController.list.length + 1,
itemBuilder: header == null
? itemBuilder
: (context, index) {
if (index == 0) {
return header;
}
return itemBuilder.call(context, index - 1);
},
separatorBuilder: header == null
? (separatorBuilder ?? (context, i) => const SizedBox())
: (context, index) {
if (index == 0) {
return const SizedBox();
}
return separatorBuilder?.call(context, index - 1) ??
const SizedBox();
},
),
),
Offstage(
offstage: !pageController.pageEmpty.value,
child: AppEmptyWidget(
onRefresh: () => pageController.refreshData(),
),
),
Offstage(
offstage: !(showPageLoadding && pageController.pageLoadding.value),
child: const AppLoaddingWidget(),
),
Offstage(
offstage: !pageController.pageError.value,
child: AppErrorWidget(
errorMsg: pageController.errorMsg.value,
error: pageController.error,
onRefresh: () => pageController.refreshData(),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:remixicon/remixicon.dart';
/// 一个加载图标会旋转的加载按钮。加载图标([Remix.refresh_line])在左,文字([text])在
/// 右。
///
/// 在点击widget时会在执行[onRefresh]函数的同时旋转加载图标。加载图标会一直旋转直到该函数
/// 返还。
///
/// 加载图标会旋转不小于1秒的时间即如果[onRefresh]函数在1秒之内执行完毕加载图标会继续旋
/// 转直到距离onRefresh函数开始执行已经过了1秒。
class RefreshUntilWidget extends StatefulWidget {
final Future Function() onRefresh;
final String text;
const RefreshUntilWidget({
super.key,
required this.onRefresh,
required this.text,
});
@override
State<RefreshUntilWidget> createState() => _RefreshUntilWidgetState();
}
class _RefreshUntilWidgetState extends State<RefreshUntilWidget>
with TickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
);
late final Animation<double> _animation = CurvedAnimation(
parent: _controller,
curve: Curves.linear,
);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () async {
_controller.repeat();
// 确保在网络很好的情况下动画不会太快结束至少1秒
await Future.wait([
widget.onRefresh(),
Future.delayed(const Duration(seconds: 1)),
]);
_controller.stop(canceled: false);
},
child: Row(
children: [
RotationTransition(
turns: _animation,
child: const Icon(Remix.refresh_line, size: 18, color: Colors.grey),
),
AppStyle.hGap4,
Text(
widget.text,
style: const TextStyle(fontSize: 14, color: Colors.grey),
),
],
),
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:get/get.dart';
class ShadowCard extends StatelessWidget {
final Widget child;
final double radius;
final Function()? onTap;
final Function()? onLongPress;
const ShadowCard({
required this.child,
this.radius = 8.0,
this.onTap,
this.onLongPress,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(radius),
boxShadow: Get.isDarkMode
? []
: [
BoxShadow(
blurRadius: 4,
color: Colors.grey.withOpacity(.2),
)
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(radius),
child: Material(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(radius),
child: InkWell(
borderRadius: BorderRadius.circular(radius),
onTap: onTap,
onLongPress: onLongPress,
child: Container(
decoration: BoxDecoration(
borderRadius: AppStyle.radius8,
),
child: child,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:lottie/lottie.dart';
class AppEmptyWidget extends StatelessWidget {
final Function()? onRefresh;
const AppEmptyWidget({this.onRefresh, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
onTap: () {
onRefresh?.call();
},
child: Padding(
padding: AppStyle.edgeInsetsA12,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
LottieBuilder.asset(
'assets/lotties/empty.json',
width: 200,
height: 200,
repeat: false,
),
const Text(
"这里什么都没有",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:lottie/lottie.dart';
class AppErrorWidget extends StatelessWidget {
final Function()? onRefresh;
final String errorMsg;
final Error? error;
const AppErrorWidget(
{this.errorMsg = "", this.onRefresh, this.error, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
onTap: () {
onRefresh?.call();
},
child: Padding(
padding: AppStyle.edgeInsetsA12,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
LottieBuilder.asset(
'assets/lotties/error.json',
width: 260,
repeat: false,
),
Text(
"$errorMsg\r\n点击刷新",
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
Visibility(
visible: error != null,
child: Padding(
padding: AppStyle.edgeInsetsT12,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
textStyle: Get.textTheme.bodySmall,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
Utils.copyText(
"$errorMsg\n${error?.stackTrace?.toString()}");
SmartDialog.showToast("已复制详细信息");
},
child: const Text("复制详细信息"),
),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:lottie/lottie.dart';
class AppLoaddingWidget extends StatelessWidget {
const AppLoaddingWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: AppStyle.edgeInsetsA12,
child: LottieBuilder.asset(
'assets/lotties/loadding.json',
width: 200,
),
),
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:get/get.dart';
class TabAppBar extends StatelessWidget implements PreferredSizeWidget {
final List<Tab> tabs;
final TabController? controller;
final Widget? action;
const TabAppBar({required this.tabs, this.controller, this.action, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
return AnnotatedRegion<SystemUiOverlayStyle>(
value: Get.isDarkMode
? SystemUiOverlayStyle.light.copyWith(
systemNavigationBarColor: Colors.transparent,
)
: SystemUiOverlayStyle.dark.copyWith(
systemNavigationBarColor: Colors.transparent,
),
child: Container(
padding:
EdgeInsets.only(top: MediaQuery.of(context).padding.top, right: 4),
height: 56 + MediaQuery.of(context).padding.top,
child: Row(
children: [
Expanded(
child: TabBar(
isScrollable: true,
controller: controller,
labelColor: Theme.of(context).colorScheme.primary,
tabAlignment: TabAlignment.start,
unselectedLabelColor:
Get.isDarkMode ? Colors.white70 : Colors.black87,
labelStyle: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
unselectedLabelStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
labelPadding: AppStyle.edgeInsetsH12,
indicatorSize: TabBarIndicatorSize.tab,
indicatorColor: Colors.transparent,
dividerColor: Colors.transparent,
tabs: tabs,
),
),
action ?? const SizedBox(),
],
),
),
);
}
@override
Size get preferredSize => Size.fromHeight(56 + AppStyle.statusBarHeight);
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:remixicon/remixicon.dart';
class UserPhoto extends StatelessWidget {
final String? url;
final bool showBoder;
final double size;
const UserPhoto({
this.url,
this.showBoder = true,
this.size = 48,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
if (url == null || (url?.isEmpty ?? true)) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
border: showBoder
? Border.all(
color: Colors.grey.withOpacity(.2),
)
: null,
color: Colors.grey.withOpacity(.2),
borderRadius: AppStyle.radius32,
),
child: const Icon(
Remix.user_fill,
color: Colors.white,
size: 24,
),
);
}
return Container(
width: size,
height: size,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(56),
border: showBoder
? Border.all(
color: Colors.grey.withOpacity(.2),
)
: null,
),
child: NetImage(
url!,
width: size,
height: size,
borderRadius: size,
),
);
}
}

View File

@@ -0,0 +1,144 @@
import 'package:fluent_ui/fluent_ui.dart' as fluent;
import 'package:flutter/material.dart';
/// Windows平台专用的标签页容器使用Fluent UI的TabView样式
/// 替代移动端的Scaffold + TabAppBar + TabBarView组合
class WindowsTabPage extends StatefulWidget {
final List<WindowsTabItem> tabs;
final int initialIndex;
final Widget? headerAction;
final ValueChanged<int>? onTabChanged;
const WindowsTabPage({
Key? key,
required this.tabs,
this.initialIndex = 0,
this.headerAction,
this.onTabChanged,
}) : super(key: key);
@override
State<WindowsTabPage> createState() => _WindowsTabPageState();
}
class _WindowsTabPageState extends State<WindowsTabPage> {
late int _currentIndex;
@override
void initState() {
super.initState();
_currentIndex = widget.initialIndex;
}
@override
Widget build(BuildContext context) {
// 使用maybeOf避免没有FluentTheme祖先时抛出异常
final fluentTheme =
fluent.FluentTheme.maybeOf(context) ?? fluent.FluentThemeData();
final materialTheme = Theme.of(context);
final isDark = materialTheme.brightness == Brightness.dark;
final tabBarBg = isDark ? const Color(0xff202020) : const Color(0xfff0f0f0);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Fluent 样式标签栏
Container(
color: tabBarBg,
child: Row(
children: [
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(widget.tabs.length, (i) {
final selected = i == _currentIndex;
return _TabButton(
label: widget.tabs[i].label,
selected: selected,
onTap: () {
setState(() => _currentIndex = i);
widget.onTabChanged?.call(i);
},
);
}),
),
),
),
if (widget.headerAction != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: widget.headerAction!,
),
],
),
),
// 分隔线
Divider(
height: 1,
thickness: 1,
color: materialTheme.dividerColor,
),
// 内容区
Expanded(
child: IndexedStack(
index: _currentIndex,
children: widget.tabs.map((t) => t.body).toList(),
),
),
],
);
}
}
class WindowsTabItem {
final String label;
final Widget body;
const WindowsTabItem({required this.label, required this.body});
}
/// 单个标签按钮Fluent Pivot样式
class _TabButton extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const _TabButton({
required this.label,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final fluentTheme =
fluent.FluentTheme.maybeOf(context) ?? fluent.FluentThemeData();
final accent = fluentTheme.accentColor;
final textColor = selected
? accent
: fluentTheme.resources.textFillColorSecondary;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected ? accent : Colors.transparent,
width: 2,
),
),
),
child: Text(
label,
style: TextStyle(
fontSize: selected ? 18 : 15,
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
color: textColor,
),
),
),
);
}
}