349 lines
9.0 KiB
Dart
349 lines
9.0 KiB
Dart
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:forui/forui.dart';
|
||
|
|
|
||
|
|
import '../../../shared/widgets/app_loading_ring.dart';
|
||
|
|
import '../../../shared/widgets/auto_dismiss_menu.dart';
|
||
|
|
|
||
|
|
|
||
|
|
class HeroIconActionButton extends StatelessWidget {
|
||
|
|
final IconData icon;
|
||
|
|
final double height;
|
||
|
|
final VoidCallback onPressed;
|
||
|
|
final String? label;
|
||
|
|
|
||
|
|
const HeroIconActionButton({
|
||
|
|
super.key,
|
||
|
|
required this.icon,
|
||
|
|
required this.height,
|
||
|
|
required this.onPressed,
|
||
|
|
this.label,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
if (label != null) {
|
||
|
|
return GestureDetector(
|
||
|
|
onTap: onPressed,
|
||
|
|
behavior: HitTestBehavior.opaque,
|
||
|
|
child: SizedBox(
|
||
|
|
height: height,
|
||
|
|
child: Column(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
||
|
|
children: [
|
||
|
|
Icon(icon, size: 24, color: Colors.white),
|
||
|
|
const SizedBox(height: 4),
|
||
|
|
Text(
|
||
|
|
label!,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 11,
|
||
|
|
color: Colors.white.withValues(alpha: 0.7),
|
||
|
|
),
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return SizedBox(
|
||
|
|
height: height,
|
||
|
|
child: OutlinedButton(
|
||
|
|
onPressed: onPressed,
|
||
|
|
style: OutlinedButton.styleFrom(
|
||
|
|
foregroundColor: Colors.white,
|
||
|
|
side: BorderSide(color: Colors.white.withValues(alpha: 0.4)),
|
||
|
|
shape: RoundedRectangleBorder(
|
||
|
|
borderRadius: BorderRadius.circular(12),
|
||
|
|
),
|
||
|
|
padding: EdgeInsets.zero,
|
||
|
|
),
|
||
|
|
child: Icon(icon, size: height * 0.44),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
final _heroPillBorder = StadiumBorder(
|
||
|
|
side: BorderSide(color: Colors.white.withValues(alpha: 0.55)),
|
||
|
|
);
|
||
|
|
|
||
|
|
const _heroDropdownMenuMaxHeight = 320.0;
|
||
|
|
|
||
|
|
class HeroPillDropdownButton extends StatelessWidget {
|
||
|
|
final IconData icon;
|
||
|
|
final List<FItem> menuChildren;
|
||
|
|
final double height;
|
||
|
|
|
||
|
|
const HeroPillDropdownButton({
|
||
|
|
super.key,
|
||
|
|
required this.icon,
|
||
|
|
required this.menuChildren,
|
||
|
|
this.height = 52,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
return FPopoverMenu(
|
||
|
|
menuBuilder: autoDismissMenuBuilder,
|
||
|
|
menuAnchor: Alignment.topCenter,
|
||
|
|
childAnchor: Alignment.bottomCenter,
|
||
|
|
maxHeight: _heroDropdownMenuMaxHeight,
|
||
|
|
menu: [FItemGroup(children: menuChildren)],
|
||
|
|
builder: (context, controller, child) => GestureDetector(
|
||
|
|
onTap: controller.toggle,
|
||
|
|
child: child,
|
||
|
|
),
|
||
|
|
child: Material(
|
||
|
|
color: Colors.transparent,
|
||
|
|
shape: _heroPillBorder,
|
||
|
|
clipBehavior: Clip.antiAlias,
|
||
|
|
child: SizedBox(
|
||
|
|
height: height,
|
||
|
|
child: Padding(
|
||
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||
|
|
child: Icon(icon, size: 22, color: Colors.white),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class DetailHeroActions extends StatelessWidget {
|
||
|
|
|
||
|
|
final bool showReplay;
|
||
|
|
|
||
|
|
|
||
|
|
final VoidCallback? onReplay;
|
||
|
|
|
||
|
|
final bool isPlayed;
|
||
|
|
final VoidCallback onTogglePlayed;
|
||
|
|
final bool isFavorite;
|
||
|
|
final VoidCallback onToggleFavorite;
|
||
|
|
|
||
|
|
|
||
|
|
final List<Widget> extraIconActions;
|
||
|
|
|
||
|
|
|
||
|
|
final VoidCallback? onPlay;
|
||
|
|
|
||
|
|
|
||
|
|
final String playLabel;
|
||
|
|
|
||
|
|
|
||
|
|
final double? progressPercent;
|
||
|
|
|
||
|
|
|
||
|
|
final String? trailingLabel;
|
||
|
|
|
||
|
|
final bool isPlayLoading;
|
||
|
|
final bool compact;
|
||
|
|
|
||
|
|
const DetailHeroActions({
|
||
|
|
super.key,
|
||
|
|
this.showReplay = false,
|
||
|
|
this.onReplay,
|
||
|
|
required this.isPlayed,
|
||
|
|
required this.onTogglePlayed,
|
||
|
|
required this.isFavorite,
|
||
|
|
required this.onToggleFavorite,
|
||
|
|
this.extraIconActions = const [],
|
||
|
|
this.onPlay,
|
||
|
|
this.playLabel = '播放',
|
||
|
|
this.progressPercent,
|
||
|
|
this.trailingLabel,
|
||
|
|
this.isPlayLoading = false,
|
||
|
|
required this.compact,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final btnH = compact ? 44.0 : 52.0;
|
||
|
|
|
||
|
|
final iconRow = Row(
|
||
|
|
children: [
|
||
|
|
if (showReplay) ...[
|
||
|
|
Expanded(
|
||
|
|
child: HeroIconActionButton(
|
||
|
|
icon: Icons.replay,
|
||
|
|
height: btnH,
|
||
|
|
label: compact ? '重播' : null,
|
||
|
|
onPressed: onReplay ?? () {},
|
||
|
|
),
|
||
|
|
),
|
||
|
|
SizedBox(width: compact ? 0 : 10),
|
||
|
|
],
|
||
|
|
Expanded(
|
||
|
|
child: HeroIconActionButton(
|
||
|
|
icon: isPlayed ? Icons.check_circle : Icons.check_circle_outline,
|
||
|
|
height: btnH,
|
||
|
|
label: compact ? '已看' : null,
|
||
|
|
onPressed: onTogglePlayed,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
SizedBox(width: compact ? 0 : 10),
|
||
|
|
Expanded(
|
||
|
|
child: HeroIconActionButton(
|
||
|
|
icon: isFavorite ? Icons.favorite : Icons.favorite_border,
|
||
|
|
height: btnH,
|
||
|
|
label: compact ? '收藏' : null,
|
||
|
|
onPressed: onToggleFavorite,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
for (final extra in extraIconActions) ...[
|
||
|
|
SizedBox(width: compact ? 0 : 10),
|
||
|
|
Expanded(child: extra),
|
||
|
|
],
|
||
|
|
],
|
||
|
|
);
|
||
|
|
|
||
|
|
final playButton = onPlay != null
|
||
|
|
? HeroPlayButton(
|
||
|
|
height: btnH,
|
||
|
|
label: playLabel,
|
||
|
|
progressPercent: progressPercent,
|
||
|
|
trailingLabel: trailingLabel,
|
||
|
|
isLoading: isPlayLoading,
|
||
|
|
compact: compact,
|
||
|
|
onPressed: onPlay,
|
||
|
|
)
|
||
|
|
: null;
|
||
|
|
|
||
|
|
return ConstrainedBox(
|
||
|
|
constraints: BoxConstraints(maxWidth: compact ? double.infinity : 360),
|
||
|
|
child: Column(
|
||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
|
|
children: compact
|
||
|
|
? [
|
||
|
|
if (playButton != null) ...[
|
||
|
|
playButton,
|
||
|
|
const SizedBox(height: 16),
|
||
|
|
],
|
||
|
|
iconRow,
|
||
|
|
]
|
||
|
|
: [
|
||
|
|
iconRow,
|
||
|
|
if (playButton != null) ...[
|
||
|
|
const SizedBox(height: 12),
|
||
|
|
playButton,
|
||
|
|
],
|
||
|
|
],
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class HeroPlayButton extends StatelessWidget {
|
||
|
|
final double height;
|
||
|
|
final String label;
|
||
|
|
final IconData icon;
|
||
|
|
final double? progressPercent;
|
||
|
|
final String? trailingLabel;
|
||
|
|
final bool isLoading;
|
||
|
|
final String loadingLabel;
|
||
|
|
final bool compact;
|
||
|
|
final VoidCallback? onPressed;
|
||
|
|
|
||
|
|
const HeroPlayButton({
|
||
|
|
super.key,
|
||
|
|
required this.height,
|
||
|
|
required this.label,
|
||
|
|
this.icon = Icons.play_arrow,
|
||
|
|
this.progressPercent,
|
||
|
|
this.trailingLabel,
|
||
|
|
this.isLoading = false,
|
||
|
|
this.loadingLabel = '启动中',
|
||
|
|
this.compact = false,
|
||
|
|
required this.onPressed,
|
||
|
|
});
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final radius = BorderRadius.circular(12);
|
||
|
|
final hasProgress = progressPercent != null && progressPercent! > 0;
|
||
|
|
|
||
|
|
final buttonChild = Row(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
||
|
|
children: [
|
||
|
|
isLoading
|
||
|
|
? const AppLoadingRing(size: 18, color: Colors.black54)
|
||
|
|
: Icon(icon, size: compact ? 22 : 26),
|
||
|
|
const SizedBox(width: 6),
|
||
|
|
Text(
|
||
|
|
isLoading ? loadingLabel : label,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: compact ? 15 : 16,
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
if (trailingLabel != null && !isLoading) ...[
|
||
|
|
const SizedBox(width: 10),
|
||
|
|
Text(
|
||
|
|
trailingLabel!,
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: compact ? 13 : 14,
|
||
|
|
color: Colors.black.withValues(alpha: 0.5),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
],
|
||
|
|
);
|
||
|
|
|
||
|
|
if (hasProgress) {
|
||
|
|
final fraction = (progressPercent! / 100).clamp(0.0, 1.0);
|
||
|
|
return ClipRRect(
|
||
|
|
borderRadius: radius,
|
||
|
|
child: SizedBox(
|
||
|
|
height: height,
|
||
|
|
child: Stack(
|
||
|
|
children: [
|
||
|
|
Positioned.fill(
|
||
|
|
child: DecoratedBox(
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
gradient: LinearGradient(
|
||
|
|
colors: const [Color(0xFFE3E3E3), Colors.white],
|
||
|
|
stops: [fraction, fraction],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
Positioned.fill(
|
||
|
|
child: FilledButton(
|
||
|
|
onPressed: isLoading ? null : onPressed,
|
||
|
|
style: FilledButton.styleFrom(
|
||
|
|
backgroundColor: Colors.transparent,
|
||
|
|
foregroundColor: Colors.black,
|
||
|
|
overlayColor: Colors.black.withValues(alpha: 0.08),
|
||
|
|
shadowColor: Colors.transparent,
|
||
|
|
elevation: 0,
|
||
|
|
shape: RoundedRectangleBorder(borderRadius: radius),
|
||
|
|
),
|
||
|
|
child: buttonChild,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return SizedBox(
|
||
|
|
height: height,
|
||
|
|
child: FilledButton(
|
||
|
|
onPressed: isLoading ? null : onPressed,
|
||
|
|
style: FilledButton.styleFrom(
|
||
|
|
backgroundColor: Colors.white,
|
||
|
|
foregroundColor: Colors.black,
|
||
|
|
shape: RoundedRectangleBorder(borderRadius: radius),
|
||
|
|
),
|
||
|
|
child: buttonChild,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|