Initial commit
This commit is contained in:
@@ -0,0 +1,320 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../utils/format_utils.dart';
|
||||
import 'adaptive_modal.dart';
|
||||
import 'app_loading_ring.dart';
|
||||
import 'app_snack_bar.dart';
|
||||
import 'version_filter.dart';
|
||||
import 'version_grouping.dart';
|
||||
import 'version_source_card.dart';
|
||||
|
||||
const int kCompactInlineVersionLimit = 6;
|
||||
const int kDesktopInlineVersionLimit = 8;
|
||||
|
||||
int inlineVersionLimit() {
|
||||
return Platform.isAndroid
|
||||
? kCompactInlineVersionLimit
|
||||
: kDesktopInlineVersionLimit;
|
||||
}
|
||||
|
||||
Future<void> showAllVersionsSheet({
|
||||
required BuildContext context,
|
||||
required List<VersionSourceGroup> groups,
|
||||
String title = '全部版本',
|
||||
}) async {
|
||||
final isSingleVersionPicker = groups.length == 1;
|
||||
await showAdaptiveModal<void>(
|
||||
context: context,
|
||||
maxWidth: isSingleVersionPicker ? 520 : 720,
|
||||
maxHeight: isSingleVersionPicker ? 300 : 640,
|
||||
compactMaxHeightFactor: isSingleVersionPicker ? 0.55 : 0.92,
|
||||
compactHeightFactor: isSingleVersionPicker ? null : 0.82,
|
||||
builder: (modalContext) => AllVersionsSheet(groups: groups, title: title),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
class AllVersionsSheet extends StatelessWidget {
|
||||
final List<VersionSourceGroup> groups;
|
||||
final String title;
|
||||
|
||||
const AllVersionsSheet({
|
||||
super.key,
|
||||
required this.groups,
|
||||
this.title = '全部版本',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final groupsByBucket = <String, List<VersionSourceGroup>>{};
|
||||
for (final group in groups) {
|
||||
final bucket = versionBucketOf(group.representative.resolutionLabel);
|
||||
groupsByBucket.putIfAbsent(bucket, () => []).add(group);
|
||||
}
|
||||
|
||||
final concreteSourceCount = groups.fold<int>(
|
||||
0,
|
||||
(count, group) => count + group.members.length,
|
||||
);
|
||||
|
||||
return Material(
|
||||
color: const Color(0xFF171A20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.video_library_outlined,
|
||||
color: Colors.white70,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$concreteSourceCount 个来源',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.55),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(Icons.close, color: Colors.white60),
|
||||
tooltip: '关闭',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: Colors.white.withValues(alpha: 0.08)),
|
||||
Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
|
||||
children: [
|
||||
for (final bucket in kVersionBucketOrder)
|
||||
if (groupsByBucket[bucket] case final bucketGroups?) ...[
|
||||
_BucketHeader(label: bucket, count: bucketGroups.length),
|
||||
const SizedBox(height: 8),
|
||||
for (
|
||||
var index = 0;
|
||||
index < bucketGroups.length;
|
||||
index++
|
||||
) ...[
|
||||
_VersionGroupRow(group: bucketGroups[index]),
|
||||
if (index < bucketGroups.length - 1)
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BucketHeader extends StatelessWidget {
|
||||
final String label;
|
||||
final int count;
|
||||
|
||||
const _BucketHeader({required this.label, required this.count});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$count 个版本',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.5),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VersionGroupRow extends StatelessWidget {
|
||||
final VersionSourceGroup group;
|
||||
|
||||
const _VersionGroupRow({required this.group});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final representative = group.representative;
|
||||
final sizeBytes = representative.sizeBytes;
|
||||
final bitrate = representative.bitrate;
|
||||
final details = <String>[
|
||||
if (sizeBytes != null && sizeBytes > 0) formatFileSize(sizeBytes),
|
||||
if (bitrate != null && bitrate > 0)
|
||||
'${(bitrate / 1e6).toStringAsFixed(2)}Mbps',
|
||||
];
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
representative.topLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (details.isNotEmpty)
|
||||
Text(
|
||||
details.join(' · '),
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: 0.58),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
for (final member in group.members)
|
||||
_ServerSourceChip(data: member),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerSourceChip extends StatefulWidget {
|
||||
final VersionSourceCardData data;
|
||||
|
||||
const _ServerSourceChip({required this.data});
|
||||
|
||||
@override
|
||||
State<_ServerSourceChip> createState() => _ServerSourceChipState();
|
||||
}
|
||||
|
||||
class _ServerSourceChipState extends State<_ServerSourceChip> {
|
||||
bool _loading = false;
|
||||
|
||||
Future<void> _selectSource() async {
|
||||
final data = widget.data;
|
||||
if (_loading || data.isCurrent || data.selected || data.onTap == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
await data.onTap!();
|
||||
if (mounted) Navigator.of(context).pop();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _loading = false);
|
||||
showAppSnackBar(context, '切换服务器失败', tone: AppSnackTone.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = widget.data;
|
||||
final active = data.isCurrent || data.selected;
|
||||
|
||||
return Tooltip(
|
||||
message: active ? '${data.serverName}(当前选择)' : '切换到 ${data.serverName}',
|
||||
child: InkWell(
|
||||
onTap: active ? null : _selectSource,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: active
|
||||
? const Color(0xFF4F8DFF).withValues(alpha: 0.24)
|
||||
: Colors.white.withValues(alpha: 0.07),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: active
|
||||
? const Color(0xFF4F8DFF)
|
||||
: Colors.white.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_loading)
|
||||
const AppLoadingRing(size: 12, color: Colors.white)
|
||||
else
|
||||
Icon(
|
||||
active ? Icons.check_circle : Icons.dns_outlined,
|
||||
size: 14,
|
||||
color: active ? const Color(0xFF77A7FF) : Colors.white60,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
data.serverName,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withValues(alpha: active ? 1 : 0.78),
|
||||
fontSize: 12,
|
||||
fontWeight: active ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (data.isCurrent) ...[
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'当前',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF9FC0FF),
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user