Initial commit
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:forui/forui.dart';
|
||||
|
||||
import '../../core/contracts/library.dart';
|
||||
|
||||
const Color _defaultSeasonAccentColor = Color(0xFF4F8DFF);
|
||||
const double _inlineSeasonHorizontalPadding = 14;
|
||||
const double _inlineSeasonVerticalPadding = 6;
|
||||
const double _inlineSeasonGap = 8;
|
||||
const double _inlineWidthSafetyMargin = 2;
|
||||
const TextStyle _inlineSeasonTextStyle = TextStyle(fontSize: 13);
|
||||
|
||||
class SeasonSelector extends StatelessWidget {
|
||||
final List<EmbyRawSeason> seasons;
|
||||
final String selectedId;
|
||||
final ValueChanged<String> onSelect;
|
||||
final Color accentColor;
|
||||
final Color? backgroundColor;
|
||||
|
||||
const SeasonSelector({
|
||||
super.key,
|
||||
required this.seasons,
|
||||
required this.selectedId,
|
||||
required this.onSelect,
|
||||
this.accentColor = _defaultSeasonAccentColor,
|
||||
this.backgroundColor,
|
||||
});
|
||||
|
||||
EmbyRawSeason _resolveSelectedSeason() {
|
||||
for (final season in seasons) {
|
||||
if (season.Id == selectedId) return season;
|
||||
}
|
||||
return seasons.first;
|
||||
}
|
||||
|
||||
TextStyle _inlineTextStyle(bool selected) {
|
||||
return _inlineSeasonTextStyle.copyWith(
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: selected ? Colors.white : Colors.white.withValues(alpha: 0.7),
|
||||
);
|
||||
}
|
||||
|
||||
double _calculateInlineWidth(BuildContext context) {
|
||||
final textDirection = Directionality.of(context);
|
||||
final textScaler = MediaQuery.textScalerOf(context);
|
||||
var requiredWidth = 0.0;
|
||||
|
||||
for (var index = 0; index < seasons.length; index++) {
|
||||
final season = seasons[index];
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: season.Name,
|
||||
style: _inlineTextStyle(season.Id == selectedId),
|
||||
),
|
||||
maxLines: 1,
|
||||
textDirection: textDirection,
|
||||
textScaler: textScaler,
|
||||
)..layout();
|
||||
requiredWidth += textPainter.width + (_inlineSeasonHorizontalPadding * 2);
|
||||
if (index > 0) requiredWidth += _inlineSeasonGap;
|
||||
}
|
||||
|
||||
return requiredWidth + _inlineWidthSafetyMargin;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (seasons.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final selectedSeason = _resolveSelectedSeason();
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final inlineWidth = _calculateInlineWidth(context);
|
||||
final hasEnoughInlineSpace =
|
||||
!constraints.hasBoundedWidth || inlineWidth <= constraints.maxWidth;
|
||||
|
||||
final selector = hasEnoughInlineSpace
|
||||
? _buildInlineOptions()
|
||||
: _buildDropdownSelect(context, selectedSeason);
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: 1,
|
||||
child: selector,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInlineOptions() {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (var index = 0; index < seasons.length; index++) ...[
|
||||
if (index > 0) const SizedBox(width: _inlineSeasonGap),
|
||||
_buildInlineOption(seasons[index]),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInlineOption(EmbyRawSeason season) {
|
||||
final selected = season.Id == selectedId;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: selected ? null : () => onSelect(season.Id),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: _inlineSeasonHorizontalPadding,
|
||||
vertical: _inlineSeasonVerticalPadding,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? accentColor.withValues(alpha: 0.28)
|
||||
: backgroundColor ?? Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(season.Name, style: _inlineTextStyle(selected)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDropdownSelect(
|
||||
BuildContext context,
|
||||
EmbyRawSeason selectedSeason,
|
||||
) {
|
||||
final controlWidth = _calculateControlWidth(context, selectedSeason);
|
||||
final menuWidth = _calculateMenuWidth(context, controlWidth);
|
||||
|
||||
return SizedBox(
|
||||
width: controlWidth,
|
||||
child: FSelect<String>.rich(
|
||||
control: FSelectControl<String>.lifted(
|
||||
value: selectedId,
|
||||
onChange: (seasonId) {
|
||||
if (seasonId != null && seasonId != selectedId) {
|
||||
onSelect(seasonId);
|
||||
}
|
||||
},
|
||||
),
|
||||
format: _seasonNameForId,
|
||||
contentConstraints: FPortalConstraints(
|
||||
minWidth: menuWidth,
|
||||
maxWidth: menuWidth,
|
||||
maxHeight: 360,
|
||||
),
|
||||
children: [
|
||||
for (final season in seasons)
|
||||
FSelectItem<String>.item(
|
||||
title: Text(season.Name),
|
||||
subtitle: season.ChildCount == null
|
||||
? null
|
||||
: Text('${season.ChildCount} 集'),
|
||||
value: season.Id,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _calculateControlWidth(
|
||||
BuildContext context,
|
||||
EmbyRawSeason selectedSeason,
|
||||
) {
|
||||
final selectedTextWidth = _measureTextWidth(
|
||||
context,
|
||||
selectedSeason.Name,
|
||||
_inlineSeasonTextStyle.copyWith(fontWeight: FontWeight.w600),
|
||||
);
|
||||
|
||||
|
||||
return (selectedTextWidth + 64).clamp(104.0, 200.0).toDouble();
|
||||
}
|
||||
|
||||
double _calculateMenuWidth(BuildContext context, double controlWidth) {
|
||||
var widestContent = 0.0;
|
||||
for (final season in seasons) {
|
||||
final seasonNameWidth = _measureTextWidth(
|
||||
context,
|
||||
season.Name,
|
||||
const TextStyle(fontSize: 14),
|
||||
);
|
||||
final episodeCountWidth = season.ChildCount == null
|
||||
? 0.0
|
||||
: _measureTextWidth(
|
||||
context,
|
||||
'${season.ChildCount} 集',
|
||||
const TextStyle(fontSize: 12),
|
||||
);
|
||||
widestContent = widestContent.clamp(
|
||||
seasonNameWidth > episodeCountWidth
|
||||
? seasonNameWidth
|
||||
: episodeCountWidth,
|
||||
double.infinity,
|
||||
);
|
||||
}
|
||||
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final maximumSafeWidth = (screenWidth - 32).clamp(
|
||||
controlWidth,
|
||||
double.infinity,
|
||||
);
|
||||
|
||||
|
||||
return (widestContent + 72)
|
||||
.clamp(controlWidth, maximumSafeWidth)
|
||||
.toDouble();
|
||||
}
|
||||
|
||||
double _measureTextWidth(BuildContext context, String text, TextStyle style) {
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(text: text, style: style),
|
||||
maxLines: 1,
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
)..layout();
|
||||
return textPainter.width;
|
||||
}
|
||||
|
||||
String _seasonNameForId(String seasonId) {
|
||||
for (final season in seasons) {
|
||||
if (season.Id == seasonId) return season.Name;
|
||||
}
|
||||
return _resolveSelectedSeason().Name;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user