初始化
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
//查看更多
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
|
||||
import '../../../hj_utils/widget_util.dart';
|
||||
|
||||
class AcgShowMoreTextWidget extends StatefulWidget {
|
||||
final String? summary;
|
||||
final int? maxLine;
|
||||
final double? maxWidth;
|
||||
final Color txColor;
|
||||
|
||||
const AcgShowMoreTextWidget({
|
||||
super.key,
|
||||
this.summary,
|
||||
this.maxLine = 3,
|
||||
this.maxWidth,
|
||||
this.txColor = const Color(0xff989898),
|
||||
});
|
||||
|
||||
@override
|
||||
State<AcgShowMoreTextWidget> createState() => _AcgShowMoreTextWidgetState();
|
||||
}
|
||||
|
||||
class _AcgShowMoreTextWidgetState extends State<AcgShowMoreTextWidget> {
|
||||
double turns = 1.0; //翻转动画累加值,整数=折叠态,每点击 +0.5 转半圈
|
||||
|
||||
bool get _isFold => turns % 1 == 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
//用 LayoutBuilder 拿真实约束宽度测量,避免传入的 maxWidth 与实际渲染宽度不符导致"该展开却没按钮"
|
||||
return LayoutBuilder(builder: (context, cons) {
|
||||
final maxWidth = cons.maxWidth.isFinite
|
||||
? cons.maxWidth
|
||||
: (widget.maxWidth ?? Get.width);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
//宽度拉满 + 居左:短文本也占满整行左对齐,避免父级把窄 Column 居中
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Text(
|
||||
widget.summary?.isNotEmpty == true
|
||||
? widget.summary ?? ''
|
||||
: '暂无简介',
|
||||
textAlign: TextAlign.left,
|
||||
style: textStyle(12, widget.txColor, FontWeight.w400),
|
||||
maxLines: _isFold ? widget.maxLine : null,
|
||||
overflow: _isFold ? TextOverflow.ellipsis : null,
|
||||
),
|
||||
),
|
||||
if (_showMoreText(maxWidth))
|
||||
GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: _toggleExpand,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_isFold ? '更多简介' : '收起简介',
|
||||
style: textStyle(
|
||||
12, const Color(0xffEFEFEF), FontWeight.w400),
|
||||
),
|
||||
2.sizeBoxW,
|
||||
AnimatedRotation(
|
||||
turns: turns,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Image.asset(
|
||||
'acg_narrow.png'.acgImgPath,
|
||||
width: 18,
|
||||
color: const Color(0xffDCDCDC),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
//展开/收起
|
||||
void _toggleExpand() => setState(() => turns += 0.5);
|
||||
|
||||
//文本超过 maxLine 才显示"更多简介"按钮
|
||||
bool _showMoreText(double maxWidth) {
|
||||
return _textExceedMaxLines(
|
||||
widget.summary ?? '.',
|
||||
textStyle(12, widget.txColor, FontWeight.w400),
|
||||
widget.maxLine ?? 3,
|
||||
maxWidth,
|
||||
);
|
||||
}
|
||||
|
||||
bool _textExceedMaxLines(
|
||||
String text, TextStyle style, int maxLine, double maxWidth) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
maxLines: maxLine,
|
||||
textDirection: TextDirection.ltr,
|
||||
)..layout(maxWidth: maxWidth);
|
||||
return painter.didExceedMaxLines;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/hj_model/cartoon_media_info.dart';
|
||||
import 'package:hgdj/hj_model/media_content.dart';
|
||||
import 'package:hgdj/hj_utils/api_service/common_service.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/event_bus_util.dart';
|
||||
import 'package:hgdj/tools_base/event_bus/events.dart';
|
||||
import 'package:hgdj/tools_base/loading/loading_helper.dart';
|
||||
import 'package:hgdj/tools_base/toast.dart';
|
||||
|
||||
import '../../../alert/video/comic_buy_alert.dart';
|
||||
import '../../../alert/video/vip_acg_dialog.dart';
|
||||
import '../../../hj_model/acg/comic_chapters_model.dart';
|
||||
import '../../../hj_utils/api_service/acg_service.dart';
|
||||
import '../../../tools_base/global_store/store.dart';
|
||||
|
||||
/// 小说/漫画/有声资源管理器
|
||||
class ACGSourceManager extends ChangeNotifier {
|
||||
CartoonMediaInfo? mediaInfo;
|
||||
List<ComicChapterInfo> allEpisodes = <ComicChapterInfo>[];
|
||||
final mediaContentCache = <int, MediaContent>{}; // 章节详情缓存
|
||||
|
||||
static const _maxPage = 50; // 章节分页拉取上限(50 页 × 60 条 = 3000 章)
|
||||
static const _pageSize = 60; // 每页集数:一页尽量拉多点,少几轮串行翻页
|
||||
|
||||
String id;
|
||||
int page = 1;
|
||||
int index = 0; // 当前选中下标,从 0 开始
|
||||
|
||||
ACGSourceManager(this.id);
|
||||
|
||||
/// 更新收藏状态
|
||||
void updateCollectState(bool collected) {
|
||||
mediaInfo?.mediaStatus?.hasCollected = collected;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 点赞/取消点赞
|
||||
Future<void> onLikeAction() async {
|
||||
LoadingHelper.showLoading();
|
||||
final liked = mediaInfo?.mediaStatus?.hasLiked ?? false;
|
||||
final mid = mediaInfo?.id ?? '';
|
||||
final type = mediaInfo?.mediaType ?? '';
|
||||
final suc = liked
|
||||
? await CommonService.cancelLike(mid, type)
|
||||
: await CommonService.sendLike(mid, type);
|
||||
LoadingHelper.dismissLoading();
|
||||
if (suc) {
|
||||
mediaInfo?.mediaStatus?.hasLiked = !liked;
|
||||
// 取消 like 数 -1;点 like 数 +1(原代码两个分支都 -- 是 bug)
|
||||
mediaInfo?.countLike = (mediaInfo?.countLike ?? 0) + (liked ? -1 : 1);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 更新章节购买权限:buyAll=true 整本解锁,否则只解锁 subId 对应章节
|
||||
void updatePermission({bool? buyAll, String? subId}) {
|
||||
if (buyAll == true) {
|
||||
mediaInfo?.mediaStatus?.hasPaid = true;
|
||||
for (final e in allEpisodes) {
|
||||
e.hasBuy = true;
|
||||
}
|
||||
} else {
|
||||
for (final e in allEpisodes) {
|
||||
if (e.id == subId) e.hasBuy = true;
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 阅读按钮文案:第一章=开始阅读;其它=续看 NNN集
|
||||
String actionTitle() {
|
||||
final showCount = index + 1;
|
||||
if (showCount == 1) return "开始阅读";
|
||||
final showText = showCount.toString().padLeft(3, '0');
|
||||
return "续看$showText${mediaInfo?.unit ?? ''}";
|
||||
}
|
||||
|
||||
/// 切换当前播放章节
|
||||
void changeEpisodesIndex(int current, {bool jump = true}) {
|
||||
index = current;
|
||||
notifyListeners();
|
||||
if (jump) eventBus.emit(ACGMenuChanged(index: index));
|
||||
}
|
||||
|
||||
/// 拉所有章节(分页,每页 [_pageSize] 条,最多 [_maxPage] 页)
|
||||
/// 注:暂不支持刷新重拉——真要加,必须连 mediaContentCache 一起清(它以下标为 key,列表变了会串章)
|
||||
Future<void> fetchAllEpisodes() async {
|
||||
// 改为 while 循环:原代码递归没 await;再加页数上限,防后端 hasNext 恒 true 时无限翻页
|
||||
while (page <= _maxPage) {
|
||||
final result = await ACGService.getChapterList(id, page, _pageSize);
|
||||
if (result == null) break;
|
||||
final list = result.list ?? [];
|
||||
// 每页拉到就按集号标免费集(与播放放行/角标同一口径),避免全部拉完才标导致角标迟到
|
||||
for (final e in list) {
|
||||
e.inFreeEpisode =
|
||||
mediaInfo?.isFreeEpisodeNumber(e.episodeNumber) ?? false;
|
||||
}
|
||||
allEpisodes.addAll(list);
|
||||
notifyListeners();
|
||||
if (!(result.hasNext ?? false)) break;
|
||||
page += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测章节权限。permission:0=VIP可看 1=金币购买 2=免费
|
||||
/// 拿到权限后会同时切换 current index 并返回 episode;否则返回 null
|
||||
Future<ComicChapterInfo?> getEpisodeHasPermisson(int index_) async {
|
||||
if (allEpisodes.isEmpty) return null;
|
||||
final epm = allEpisodes[index_];
|
||||
final permission = mediaInfo?.permission ?? 0;
|
||||
final unLock = mediaInfo?.mediaStatus?.hasPaid ?? false;
|
||||
final isPremiumVIP = globalStore.isSuperUp;
|
||||
final inFreeEpisode = epm.inFreeEpisode; // 本地字段:前 N 集免费,直接放行
|
||||
|
||||
bool hasPermission = false;
|
||||
if (unLock || isPremiumVIP || inFreeEpisode) {
|
||||
hasPermission = true;
|
||||
} else {
|
||||
switch (permission) {
|
||||
case 2: // 免费
|
||||
hasPermission = true;
|
||||
break;
|
||||
case 0: // 需要 VIP
|
||||
hasPermission = globalStore.isVIP;
|
||||
if (!hasPermission) {
|
||||
await Get.dialog(VipACGDialog(), useSafeArea: false);
|
||||
}
|
||||
break;
|
||||
case 1: // 金币购买
|
||||
hasPermission = mediaInfo?.mediaStatus?.hasPaid ?? false;
|
||||
if (!hasPermission) {
|
||||
final result = await ComicBuyAlert.show(mediaInfo: mediaInfo);
|
||||
// 1 购买单集 2 购买全集
|
||||
if (result == 1 || result == 2) {
|
||||
hasPermission = true;
|
||||
if (result == 2) updatePermission(buyAll: true);
|
||||
epm.hasBuy = true;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasPermission) changeEpisodesIndex(index_);
|
||||
return hasPermission ? epm : null;
|
||||
}
|
||||
|
||||
/// 拉漫画章节详情(带缓存 + 权限校验)
|
||||
Future<MediaContent?> getComicsMediaContent(
|
||||
{int index = 0, bool showLoading = false}) async {
|
||||
final cached = mediaContentCache[index];
|
||||
if (cached != null) {
|
||||
changeEpisodesIndex(index); // 命中缓存也要同步当前章,否则退回详情页「续看」还停在旧章
|
||||
return cached;
|
||||
}
|
||||
final episode = await getEpisodeHasPermisson(index);
|
||||
if (episode == null) return null;
|
||||
// loading 开在权限校验之后:VIP/购买弹窗不能被 loading 遮罩挡住
|
||||
if (showLoading) LoadingHelper.showLoading();
|
||||
final result = await ACGService.getMediaDetail(id: episode.id ?? '');
|
||||
if (showLoading) LoadingHelper.dismissLoading();
|
||||
if (result != null) mediaContentCache[index] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 上一集
|
||||
Future<ComicChapterInfo?> backToForward() async {
|
||||
if (index == 0) {
|
||||
showToast('当前是第一章~~~');
|
||||
return null;
|
||||
}
|
||||
return getEpisodeHasPermisson(index - 1);
|
||||
}
|
||||
|
||||
/// 下一集
|
||||
Future<ComicChapterInfo?> jumpToNext() async {
|
||||
if (index == allEpisodes.length - 1) {
|
||||
showToast('当前是最后一章了~~~');
|
||||
return null;
|
||||
}
|
||||
return getEpisodeHasPermisson(index + 1);
|
||||
}
|
||||
}
|
||||
|
||||
enum AudioPlayMod {
|
||||
loop,
|
||||
loopList,
|
||||
}
|
||||
|
||||
class AudioPlayerValue {
|
||||
Duration? position;
|
||||
Duration? duration;
|
||||
bool? isPlaying;
|
||||
bool isLooping;
|
||||
double volume;
|
||||
bool isCompleted;
|
||||
bool isInitialized;
|
||||
int? onPlayIndex;
|
||||
|
||||
AudioPlayerValue({
|
||||
this.position,
|
||||
this.duration,
|
||||
this.isPlaying,
|
||||
this.volume = 1,
|
||||
this.isLooping = true,
|
||||
this.isCompleted = false,
|
||||
this.isInitialized = false,
|
||||
this.onPlayIndex,
|
||||
});
|
||||
|
||||
static AudioPlayerValue copyWith(AudioPlayerValue value) {
|
||||
return AudioPlayerValue(
|
||||
duration: value.duration,
|
||||
position: value.position,
|
||||
isPlaying: value.isPlaying,
|
||||
isCompleted: value.isCompleted,
|
||||
volume: value.volume,
|
||||
isLooping: value.isLooping,
|
||||
isInitialized: value.isInitialized,
|
||||
onPlayIndex: value.onPlayIndex,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 有声小说播放器封装:包装 audioplayers + 列表切换
|
||||
class AudioPlayerManager {
|
||||
AudioPlayerManager() {
|
||||
audioValue = ValueNotifier(value);
|
||||
}
|
||||
|
||||
AudioPlayer? _audioPlayer;
|
||||
|
||||
AudioPlayMod playMod = AudioPlayMod.loopList;
|
||||
ACGSourceManager? sourceManger;
|
||||
|
||||
StreamSubscription? _audioPositionSub;
|
||||
StreamSubscription? _audioDurationSub;
|
||||
StreamSubscription? _audioStateSub;
|
||||
StreamSubscription? _audioCompleSub;
|
||||
StreamSubscription? _seekSub;
|
||||
|
||||
final AudioPlayerValue value = AudioPlayerValue();
|
||||
late ValueNotifier<AudioPlayerValue> audioValue;
|
||||
|
||||
double playSpeed = 1;
|
||||
|
||||
void initPlayer() {
|
||||
if (_audioPlayer != null)
|
||||
return; // 防重入:已初始化则跳过,避免 5 个 stream 监听重复注册、旧 sub 泄漏
|
||||
_audioPlayer = AudioPlayer();
|
||||
_audioPlayer?.setReleaseMode(
|
||||
playMod == AudioPlayMod.loop ? ReleaseMode.loop : ReleaseMode.stop);
|
||||
|
||||
_audioDurationSub = _audioPlayer?.onDurationChanged.listen((event) {
|
||||
audioValue.value = AudioPlayerValue.copyWith(value..duration = event);
|
||||
});
|
||||
|
||||
_audioPositionSub = _audioPlayer?.onPositionChanged.listen((event) {
|
||||
audioValue.value = AudioPlayerValue.copyWith(value
|
||||
..position = event
|
||||
..isPlaying = true
|
||||
..onPlayIndex = sourceManger?.index ?? 0);
|
||||
});
|
||||
|
||||
_audioStateSub = _audioPlayer?.onPlayerStateChanged.listen((event) {
|
||||
switch (event) {
|
||||
case PlayerState.completed:
|
||||
audioValue.value = AudioPlayerValue.copyWith(value
|
||||
..isCompleted = true
|
||||
..position = null);
|
||||
break;
|
||||
case PlayerState.playing:
|
||||
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = true);
|
||||
break;
|
||||
case PlayerState.paused:
|
||||
audioValue.value =
|
||||
AudioPlayerValue.copyWith(value..isPlaying = false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
_audioCompleSub = _audioPlayer?.onPlayerComplete.listen((event) {
|
||||
if (playMod == AudioPlayMod.loopList && (value.isPlaying ?? false)) {
|
||||
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = false);
|
||||
playNext();
|
||||
} else {
|
||||
audioValue.value = AudioPlayerValue.copyWith(value
|
||||
..isCompleted = true
|
||||
..isPlaying = false);
|
||||
}
|
||||
});
|
||||
|
||||
_seekSub = _audioPlayer?.onSeekComplete.listen((event) {
|
||||
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = true);
|
||||
});
|
||||
|
||||
audioValue.value =
|
||||
AudioPlayerValue.copyWith(value..isInitialized = _audioPlayer != null);
|
||||
}
|
||||
|
||||
void setPlaySpeed(double speed) {
|
||||
playSpeed = speed;
|
||||
_audioPlayer?.setPlaybackRate(speed);
|
||||
}
|
||||
|
||||
/// 开始播放:url 为空则重置状态
|
||||
void play(String url) {
|
||||
if (_audioPlayer == null) initPlayer();
|
||||
audioValue.value = AudioPlayerValue.copyWith(
|
||||
value
|
||||
..isPlaying = false
|
||||
..duration = null
|
||||
..position = Duration.zero,
|
||||
);
|
||||
if (url.isEmpty) return;
|
||||
|
||||
// play 是 Future 但不 await(保留 fire-and-forget 行为);
|
||||
// 必须挂 catchError,否则 audioplayers 30s 没收到 prepared 时抛 TimeoutException
|
||||
// 会变成未处理异常炸 console,UI 也卡在 "加载中"
|
||||
_audioPlayer?.play(UrlSource(url)).catchError((Object e) {
|
||||
debugPrint('[AudioPlayer] play failed: $e');
|
||||
audioValue.value = AudioPlayerValue.copyWith(value
|
||||
..isPlaying = false
|
||||
..isCompleted = true
|
||||
..duration = null);
|
||||
showToast('音频加载失败');
|
||||
});
|
||||
}
|
||||
|
||||
/// 上一集
|
||||
Future<void> playForward() async {
|
||||
final epm = await sourceManger?.backToForward();
|
||||
if (epm != null) {
|
||||
play(epm.getRealAudioUrl);
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
}
|
||||
|
||||
/// 下一集
|
||||
Future<void> playNext() async {
|
||||
final epm = await sourceManger?.jumpToNext();
|
||||
if (epm != null) {
|
||||
play(epm.getRealAudioUrl);
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
await _audioPlayer?.pause();
|
||||
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = false);
|
||||
}
|
||||
|
||||
Future<void> resume() async {
|
||||
await _audioPlayer?.resume();
|
||||
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = true);
|
||||
}
|
||||
|
||||
void setPlayMod(AudioPlayMod mod) {
|
||||
_audioPlayer?.setReleaseMode(
|
||||
mod == AudioPlayMod.loop ? ReleaseMode.loop : ReleaseMode.stop);
|
||||
playMod = mod;
|
||||
audioValue.value =
|
||||
AudioPlayerValue.copyWith(value..isLooping = mod == AudioPlayMod.loop);
|
||||
}
|
||||
|
||||
Future<void> playWithIndex(int index) async {
|
||||
if (index == sourceManger?.index) {
|
||||
if (!(value.isPlaying ?? false)) resume();
|
||||
return;
|
||||
}
|
||||
final epm = await sourceManger?.getEpisodeHasPermisson(index);
|
||||
if (epm != null) {
|
||||
play(epm.getRealAudioUrl);
|
||||
} else {
|
||||
pause();
|
||||
}
|
||||
}
|
||||
|
||||
/// 跳转到指定秒数
|
||||
void seek(int seconds) {
|
||||
_audioPlayer?.seek(Duration(seconds: seconds));
|
||||
if (value.isPlaying ?? false) {
|
||||
audioValue.value = AudioPlayerValue.copyWith(value..isPlaying = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 前进/后退 10 秒(dir: -1 后退,1 前进)
|
||||
void seekTenMinsWithDirection(int dir) {
|
||||
final cur = audioValue.value.position?.inSeconds ?? 0;
|
||||
final total = audioValue.value.duration?.inSeconds ?? 0;
|
||||
int target;
|
||||
if (dir == -1) {
|
||||
target = cur < 10 ? 0 : cur - 10;
|
||||
} else {
|
||||
target = (total - cur) < 10 ? total : cur + 10;
|
||||
}
|
||||
seek(target);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_audioPlayer?.pause();
|
||||
_audioPlayer?.release();
|
||||
_audioPlayer = null;
|
||||
_audioPositionSub?.cancel();
|
||||
_audioDurationSub?.cancel();
|
||||
_audioStateSub?.cancel();
|
||||
_audioCompleSub?.cancel();
|
||||
_seekSub?.cancel();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:hgdj/assets_tool/app_colors.dart';
|
||||
import 'package:hgdj/assets_tool/images.dart';
|
||||
import 'package:hgdj/hj_model/acg/comic_chapters_model.dart';
|
||||
import 'package:hgdj/hj_model/cartoon_media_info.dart';
|
||||
import 'package:hgdj/hj_utils/screen.dart';
|
||||
import 'package:hgdj/tools_base/widget/sheet_handle_bar.dart';
|
||||
import 'package:hgdj/tools_base/widget/stagger_in_item.dart';
|
||||
|
||||
import 'acg_source_manager.dart';
|
||||
import 'free_badge.dart';
|
||||
|
||||
//漫画子集底部弹窗
|
||||
class CartoonSubSetAlert extends StatefulWidget {
|
||||
final ACGSourceManager? sourceManager;
|
||||
|
||||
const CartoonSubSetAlert({super.key, this.sourceManager});
|
||||
|
||||
@override
|
||||
State<CartoonSubSetAlert> createState() => _CartoonSubSetAlertState();
|
||||
}
|
||||
|
||||
class _CartoonSubSetAlertState extends State<CartoonSubSetAlert> {
|
||||
CartoonMediaInfo? get mediaInfo => widget.sourceManager?.mediaInfo;
|
||||
List<ComicChapterInfo> allEpisodes = [];
|
||||
bool isAscending = true; // true-升序 false-降序
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
allEpisodes.addAll(widget.sourceManager?.allEpisodes ?? []);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: Get.width,
|
||||
height: 425,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primaryColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(20),
|
||||
topRight: Radius.circular(20),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
18.sizeBoxH,
|
||||
const SheetHandleBar(color: Color(0xff989898)),
|
||||
18.sizeBoxH,
|
||||
_buildHeader(),
|
||||
Expanded(child: _buildEpisodeList()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
16.sizeBoxW,
|
||||
const Text(
|
||||
'漫画列表',
|
||||
style: TextStyle(
|
||||
color: Colors.white, fontSize: 16, fontWeight: FontWeight.w500),
|
||||
),
|
||||
10.sizeBoxW,
|
||||
const Spacer(),
|
||||
InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: changeSortType,
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedRotation(
|
||||
turns: isAscending ? 0 : 0.5,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: Image.asset('text_play_sort.png'.acgImgPath, width: 20),
|
||||
),
|
||||
4.sizeBoxW,
|
||||
Text(
|
||||
isAscending ? '正序' : '倒序',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: .5),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
22.sizeBoxW,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEpisodeList() {
|
||||
return ListView.builder(
|
||||
key: ValueKey(isAscending), // 切换正/倒序时 key 变化→列表重建→item 重新错峰入场
|
||||
padding: const EdgeInsets.symmetric(vertical: 9),
|
||||
itemCount: allEpisodes.length,
|
||||
itemBuilder: (context, index) {
|
||||
final info = allEpisodes[index];
|
||||
final realIndex = (info.episodeNumber ?? 1) - 1;
|
||||
final sel = realIndex == widget.sourceManager?.index;
|
||||
return StaggerInItem(
|
||||
index: index,
|
||||
child: InkWell(
|
||||
enableFeedback: false,
|
||||
onTap: () => Get.back(result: realIndex),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 9),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
//与详情/阅读抽屉子集 title 一致:第X话(CartoonDetailPage 只处理 image,恒为漫画)
|
||||
'第${info.episodeNumber ?? ''}${mediaInfo?.unit ?? ''}',
|
||||
style: TextStyle(
|
||||
color: sel
|
||||
? Colors.white.withValues(alpha: .9)
|
||||
: Colors.white.withValues(alpha: .45),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
//无任何权限时,前 N 集免费展示「免费」角标
|
||||
if (mediaInfo?.hasPermission == false &&
|
||||
info.inFreeEpisode) ...[
|
||||
8.sizeBoxW,
|
||||
const FreeBadge(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void changeSortType() {
|
||||
isAscending = !isAscending;
|
||||
final list = widget.sourceManager?.allEpisodes ?? [];
|
||||
allEpisodes = isAscending ? List.of(list) : list.reversed.toList();
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// ACG「免费」角标:整本无任何权限(hasPermission == false)且该集落在前 N 集免费区间时展示。
|
||||
/// 默认行内胶囊(详情页/子集抽屉/有声列表);[isCorner] 为卡片左上角贴边样式(选集面板小方块)。
|
||||
class FreeBadge extends StatelessWidget {
|
||||
final bool isCorner;
|
||||
|
||||
const FreeBadge({super.key, this.isCorner = false});
|
||||
|
||||
static const _bgColor = Color(0xff64CBC2);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isCorner) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
|
||||
decoration: const BoxDecoration(
|
||||
color: _bgColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(7),
|
||||
bottomRight: Radius.circular(7),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'免费',
|
||||
style: TextStyle(color: Colors.white, fontSize: 9, height: 1.2, fontWeight: FontWeight.w400),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Container(
|
||||
height: 20,
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: _bgColor,
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: const Text(
|
||||
'免费',
|
||||
style: TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.w400),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user