activity_carousel.dart 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import 'dart:async';
  2. import 'package:cached_network_image/cached_network_image.dart';
  3. import 'package:flutter/material.dart';
  4. import 'package:flutter_riverpod/flutter_riverpod.dart';
  5. import 'package:go_router/go_router.dart';
  6. import 'package:url_launcher/url_launcher.dart';
  7. import '../../../core/navigation/broker_navigation.dart';
  8. import '../../../data/models/home/activity_banner.dart';
  9. /// 底部 Tab 根路由 — 点击 banner 跳转这些路径时用 go() 切换 tab
  10. const _tabRoutes = {'/','market', '/market', '/futures', '/copy-trading', '/asset'};
  11. /// 首页 Banner 轮播 — 从接口获取图片,支持 internal/external 跳转
  12. class ActivityCarousel extends ConsumerStatefulWidget {
  13. const ActivityCarousel({super.key, required this.banners});
  14. final List<ActivityBanner> banners;
  15. @override
  16. ConsumerState<ActivityCarousel> createState() => _ActivityCarouselState();
  17. }
  18. class _ActivityCarouselState extends ConsumerState<ActivityCarousel> {
  19. final _controller = PageController();
  20. int _currentPage = 0;
  21. Timer? _timer;
  22. @override
  23. void initState() {
  24. super.initState();
  25. _startAutoPlay();
  26. }
  27. @override
  28. void didUpdateWidget(covariant ActivityCarousel old) {
  29. super.didUpdateWidget(old);
  30. if (old.banners.length != widget.banners.length) {
  31. _timer?.cancel();
  32. _currentPage = 0;
  33. _startAutoPlay();
  34. }
  35. }
  36. @override
  37. void dispose() {
  38. _timer?.cancel();
  39. _controller.dispose();
  40. super.dispose();
  41. }
  42. void _startAutoPlay() {
  43. if (widget.banners.length <= 1) return;
  44. _timer = Timer.periodic(const Duration(seconds: 4), (_) {
  45. if (!mounted) return;
  46. final next = (_currentPage + 1) % widget.banners.length;
  47. _controller.animateToPage(
  48. next,
  49. duration: const Duration(milliseconds: 350),
  50. curve: Curves.easeInOut,
  51. );
  52. });
  53. }
  54. Future<void> _onTap(ActivityBanner banner) async {
  55. if (banner.linkUrl.isEmpty) return;
  56. if (banner.isExternal) {
  57. launchUrl(Uri.parse(banner.linkUrl), mode: LaunchMode.externalApplication);
  58. return;
  59. }
  60. final url = banner.linkUrl;
  61. // ── 经纪商页需要先验证身份 ────────────────────────────
  62. if (url == '/broker' || url.startsWith('/broker')) {
  63. await openBrokerEntry(context, ref);
  64. return;
  65. }
  66. // ── Tab 级路由用 go() 切换底部导航选中项 ──────────────
  67. if (!mounted) return;
  68. final isTabRoute = _tabRoutes.any((r) => url == r || url.startsWith('$r/'));
  69. if (isTabRoute) {
  70. context.go(url);
  71. } else {
  72. context.push(url);
  73. }
  74. }
  75. @override
  76. Widget build(BuildContext context) {
  77. if (widget.banners.isEmpty) return const SizedBox.shrink();
  78. final cs = Theme.of(context).colorScheme;
  79. final count = widget.banners.length;
  80. return Padding(
  81. padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
  82. child: Column(
  83. children: [
  84. ClipRRect(
  85. borderRadius: BorderRadius.circular(14),
  86. child: SizedBox(
  87. height: 120,
  88. child: PageView.builder(
  89. controller: _controller,
  90. onPageChanged: (i) => setState(() => _currentPage = i),
  91. itemCount: count,
  92. itemBuilder: (context, index) {
  93. final banner = widget.banners[index];
  94. return GestureDetector(
  95. onTap: () => _onTap(banner),
  96. child: banner.imageUrl.isNotEmpty
  97. ? CachedNetworkImage(
  98. imageUrl: banner.imageUrl,
  99. fit: BoxFit.cover,
  100. width: double.infinity,
  101. placeholder: (_, __) => Container(
  102. color: cs.surface,
  103. child: Center(
  104. child: Icon(
  105. Icons.image_outlined,
  106. color: cs.onSurface.withAlpha(60),
  107. size: 32,
  108. ),
  109. ),
  110. ),
  111. errorWidget: (_, __, ___) => Container(
  112. color: cs.surface,
  113. child: Center(
  114. child: Column(
  115. mainAxisSize: MainAxisSize.min,
  116. children: [
  117. Icon(Icons.broken_image_outlined,
  118. color: cs.onSurface.withAlpha(80),
  119. size: 28),
  120. const SizedBox(height: 4),
  121. Text(
  122. banner.title,
  123. style: TextStyle(
  124. color: cs.onSurface.withAlpha(120),
  125. fontSize: 12,
  126. ),
  127. ),
  128. ],
  129. ),
  130. ),
  131. ),
  132. )
  133. : Container(
  134. color: cs.surface,
  135. child: Center(
  136. child: Text(
  137. banner.title,
  138. style: TextStyle(
  139. color: cs.onSurface,
  140. fontSize: 15,
  141. fontWeight: FontWeight.w600,
  142. ),
  143. ),
  144. ),
  145. ),
  146. );
  147. },
  148. ),
  149. ),
  150. ),
  151. if (count > 1) ...[
  152. const SizedBox(height: 8),
  153. // 圆点指示器
  154. Row(
  155. mainAxisAlignment: MainAxisAlignment.center,
  156. children: List.generate(count, (i) {
  157. final active = i == _currentPage;
  158. return Container(
  159. width: active ? 16 : 6,
  160. height: 6,
  161. margin: const EdgeInsets.symmetric(horizontal: 2),
  162. decoration: BoxDecoration(
  163. color: active
  164. ? cs.onSurface
  165. : cs.onSurface.withAlpha(60),
  166. borderRadius: BorderRadius.circular(3),
  167. ),
  168. );
  169. }),
  170. ),
  171. ],
  172. ],
  173. ),
  174. );
  175. }
  176. }