233 lines
6.7 KiB
Dart
233 lines
6.7 KiB
Dart
|
|
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;
|
||
|
|
}
|
||
|
|
}
|