feat: citizen news support

This commit is contained in:
xkeyC
2025-10-26 17:44:55 +08:00
parent 8d635827c4
commit c2a512699c
18 changed files with 1747 additions and 696 deletions

View File

@@ -10,12 +10,13 @@ class FlowNumberText extends HookConsumerWidget {
final TextStyle? style;
final Curve curve;
FlowNumberText(
{super.key,
required this.targetValue,
this.duration = const Duration(seconds: 1),
this.style,
this.curve = Curves.bounceOut});
FlowNumberText({
super.key,
required this.targetValue,
this.duration = const Duration(seconds: 1),
this.style,
this.curve = Curves.bounceOut,
});
final _formatter = NumberFormat.decimalPattern();
@@ -46,9 +47,6 @@ class FlowNumberText extends HookConsumerWidget {
return timer.value?.cancel;
}, [targetValue]);
return Text(
_formatter.format(value.value.toInt()),
style: style,
);
return Text(_formatter.format(value.value.toInt()), style: style);
}
}

View File

@@ -20,19 +20,14 @@ class GridItemAnimator extends HookWidget {
@override
Widget build(BuildContext context) {
// 创建动画控制器
final animationController = useAnimationController(
duration: duration,
);
final animationController = useAnimationController(duration: duration);
// 创建不透明度动画
final opacityAnimation = useAnimation(
Tween<double>(
begin: 0.0, // 开始时完全透明
end: 1.0, // 结束时完全不透明
).animate(CurvedAnimation(
parent: animationController,
curve: Curves.easeOut,
)),
).animate(CurvedAnimation(parent: animationController, curve: Curves.easeOut)),
);
// 创建位移动画
@@ -40,23 +35,24 @@ class GridItemAnimator extends HookWidget {
Tween<double>(
begin: 1.0, // 开始位置
end: 0.0, // 结束位置
).animate(CurvedAnimation(
parent: animationController,
curve: Curves.easeOutCubic,
)),
).animate(CurvedAnimation(parent: animationController, curve: Curves.easeOutCubic)),
);
// 组件挂载后启动动画
useEffect(() {
// 根据索引计算延迟时间,实现逐个条目入场
final delay = delayPerItem * index;
bool cancelled = false;
Future.delayed(delay, () {
if (animationController.status != AnimationStatus.completed) {
if (!cancelled && animationController.status != AnimationStatus.completed) {
animationController.forward();
}
});
return null;
return () {
cancelled = true;
};
}, const []);
// 应用动画效果

115
lib/widgets/src/swiper.dart Normal file
View File

@@ -0,0 +1,115 @@
import 'package:card_swiper/card_swiper.dart';
import 'package:fluent_ui/fluent_ui.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_tilt/flutter_tilt.dart';
class HoverSwiper extends HookWidget {
const HoverSwiper({
super.key,
required this.itemCount,
required this.itemBuilder,
this.autoplayDelay = 3000,
this.paginationActiveSize = 8.0,
this.controlSize = 24,
this.controlPadding = const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
});
final int itemCount;
final IndexedWidgetBuilder itemBuilder;
final double paginationActiveSize;
final double controlSize;
final EdgeInsets controlPadding;
final int autoplayDelay;
@override
Widget build(BuildContext context) {
final isHovered = useState(false);
final controller = useMemoized(() => SwiperController());
useEffect(() {
return controller.dispose;
}, [controller]);
return MouseRegion(
onEnter: (_) {
isHovered.value = true;
controller.stopAutoplay();
},
onExit: (_) {
isHovered.value = false;
controller.startAutoplay();
},
child: Stack(
children: [
Tilt(
shadowConfig: const ShadowConfig(maxIntensity: .3),
borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)),
child: Swiper(
controller: controller,
itemCount: itemCount,
itemBuilder: itemBuilder,
autoplay: true,
autoplayDelay: autoplayDelay,
),
),
// Left control button
_buildControlButton(
isHovered: isHovered.value,
position: 'left',
onTap: () => controller.previous(),
icon: FluentIcons.chevron_left,
),
// Right control button
_buildControlButton(
isHovered: isHovered.value,
position: 'right',
onTap: () => controller.next(),
icon: FluentIcons.chevron_right,
),
],
),
);
}
/// 构建控制按钮(左/右箭头)
Widget _buildControlButton({
required bool isHovered,
required String position,
required VoidCallback onTap,
required IconData icon,
}) {
final isLeft = position == 'left';
return Positioned(
left: isLeft ? 0 : null,
right: isLeft ? null : 0,
top: 0,
bottom: 0,
child: AnimatedOpacity(
opacity: isHovered ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: IgnorePointer(
ignoring: !isHovered,
child: Center(
child: Padding(
padding: controlPadding,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: .3),
borderRadius: BorderRadius.circular(4),
),
child: Icon(icon, size: controlSize, color: Colors.white.withValues(alpha: .8)),
),
),
),
),
),
),
),
);
}
}

View File

@@ -17,53 +17,42 @@ export 'src/cache_image.dart';
export 'src/countdown_time_text.dart';
export 'src/cache_svg_image.dart';
export 'src/grid_item_animator.dart';
export 'src/swiper.dart';
export '../common/utils/async.dart';
export '../common/utils/base_utils.dart';
export 'package:starcitizen_doctor/generated/l10n.dart';
Widget makeLoading(
BuildContext context, {
double? width,
}) {
Widget makeLoading(BuildContext context, {double? width}) {
width ??= 30;
return Center(
child: SizedBox(
width: width,
height: width,
child: const ProgressRing(),
),
child: SizedBox(width: width, height: width, child: const ProgressRing()),
);
}
Widget makeDefaultPage(BuildContext context,
{Widget? titleRow,
List<Widget>? actions,
Widget? content,
bool automaticallyImplyLeading = true,
String title = "",
bool useBodyContainer = false}) {
Widget makeDefaultPage(
BuildContext context, {
Widget? titleRow,
List<Widget>? actions,
Widget? content,
bool automaticallyImplyLeading = true,
String title = "",
bool useBodyContainer = false,
}) {
return NavigationView(
appBar: NavigationAppBar(
automaticallyImplyLeading: automaticallyImplyLeading,
title: DragToMoveArea(
child: titleRow ??
Column(
children: [
Expanded(
child: Row(
children: [
Text(title),
],
),
)
],
),
),
actions: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [...?actions, const WindowButtons()],
)),
automaticallyImplyLeading: automaticallyImplyLeading,
title: DragToMoveArea(
child:
titleRow ??
Column(
children: [
Expanded(child: Row(children: [Text(title)])),
],
),
),
actions: Row(mainAxisAlignment: MainAxisAlignment.end, children: [...?actions, const WindowButtons()]),
),
content: useBodyContainer
? Container(
decoration: BoxDecoration(
@@ -85,55 +74,63 @@ class WindowButtons extends StatelessWidget {
return SizedBox(
width: 138,
height: 50,
child: WindowCaption(
brightness: theme.brightness,
backgroundColor: Colors.transparent,
),
child: WindowCaption(brightness: theme.brightness, backgroundColor: Colors.transparent),
);
}
}
List<Widget> makeMarkdownView(String description, {String? attachmentsUrl}) {
return MarkdownGenerator().buildWidgets(description,
config: MarkdownConfig(configs: [
LinkConfig(onTap: (url) {
if (url.startsWith("/") && attachmentsUrl != null) {
url = "$attachmentsUrl/$url";
}
launchUrlString(url);
}),
ImgConfig(builder: (String url, Map<String, String> attributes) {
if (url.startsWith("/") && attachmentsUrl != null) {
url = "$attachmentsUrl/$url";
}
return ExtendedImage.network(
url,
loadStateChanged: (ExtendedImageState state) {
switch (state.extendedImageLoadState) {
case LoadState.loading:
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
const ProgressRing(),
const SizedBox(height: 12),
Text(S.current.app_common_loading_images)
],
return MarkdownGenerator().buildWidgets(
description,
config: MarkdownConfig(
configs: [
LinkConfig(
onTap: (url) {
if (url.startsWith("/") && attachmentsUrl != null) {
url = "$attachmentsUrl/$url";
}
launchUrlString(url);
},
),
ImgConfig(
builder: (String url, Map<String, String> attributes) {
if (url.startsWith("/") && attachmentsUrl != null) {
url = "$attachmentsUrl/$url";
}
return ExtendedImage.network(
url,
loadStateChanged: (ExtendedImageState state) {
switch (state.extendedImageLoadState) {
case LoadState.loading:
return Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
const ProgressRing(),
const SizedBox(height: 12),
Text(S.current.app_common_loading_images),
],
),
),
),
);
case LoadState.completed:
return ExtendedRawImage(
image: state.extendedImageInfo?.image,
);
case LoadState.failed:
return Text("Loading Image error $url");
}
},
);
})
]));
);
case LoadState.completed:
return ExtendedRawImage(image: state.extendedImageInfo?.image);
case LoadState.failed:
return Button(
onPressed: () {
launchUrlString(url);
},
child: Text("Loading Image error $url"),
);
}
},
);
},
),
],
),
);
}
ColorFilter makeSvgColor(Color color) {
@@ -142,22 +139,20 @@ ColorFilter makeSvgColor(Color color) {
CustomTransitionPage<T> myPageBuilder<T>(BuildContext context, GoRouterState state, Widget child) {
return CustomTransitionPage(
child: child,
transitionDuration: const Duration(milliseconds: 150),
reverseTransitionDuration: const Duration(milliseconds: 150),
transitionsBuilder:
(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 1.0),
end: const Offset(0.0, 0.0),
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
)),
child: child,
);
});
child: child,
transitionDuration: const Duration(milliseconds: 150),
reverseTransitionDuration: const Duration(milliseconds: 150),
transitionsBuilder:
(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.0, 1.0),
end: const Offset(0.0, 0.0),
).animate(CurvedAnimation(parent: animation, curve: Curves.easeInOut)),
child: child,
);
},
);
}
class LoadingWidget<T> extends HookConsumerWidget {
@@ -192,9 +187,7 @@ class LoadingWidget<T> extends HookConsumerWidget {
onPressed: () {
_loadData(dataState, errorMsg);
},
child: Center(
child: Text(errorMsg.value),
),
child: Center(child: Text(errorMsg.value)),
);
}
if (dataState.value == null && data == null) return makeLoading(context);