Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,272 @@
library;
import '../../core/contracts/library.dart';
import '../../shared/utils/time_bucket.dart';
enum GroupAxis { watchedTime, addedTime, mediaType }
class AggregatedEntrySource {
final EmbyRawItem item;
final GlobalSearchServerResult source;
const AggregatedEntrySource({required this.item, required this.source});
DateTime? dateFor(GroupAxis axis) {
switch (axis) {
case GroupAxis.watchedTime:
final userData = item.extra['UserData'];
return parseEmbyDate(
userData is Map ? userData['LastPlayedDate'] : null,
);
case GroupAxis.addedTime:
return parseEmbyDate(item.extra['DateCreated']);
case GroupAxis.mediaType:
return null;
}
}
}
class AggregatedEntry {
final EmbyRawItem item;
final GlobalSearchServerResult source;
final List<AggregatedEntrySource> sources;
AggregatedEntry({
required this.item,
required this.source,
List<AggregatedEntrySource>? sources,
}) : assert(sources == null || sources.isNotEmpty),
sources = List.unmodifiable(
sources ?? [AggregatedEntrySource(item: item, source: source)],
);
DateTime? dateFor(GroupAxis axis) {
return sources.first.dateFor(axis);
}
}
class AggregatedGroup {
final String key;
final String label;
final int order;
final List<AggregatedEntry> entries;
const AggregatedGroup({
required this.key,
required this.label,
required this.order,
required this.entries,
});
}
List<AggregatedEntry> flatten(
List<GlobalSearchServerResult> results, {
String? serverFilter,
bool deduplicateAcrossServers = false,
}) {
final out = <AggregatedEntry>[];
for (final sr in results) {
if (serverFilter != null && sr.serverId != serverFilter) continue;
for (final item in sr.items) {
out.add(AggregatedEntry(item: item, source: sr));
}
}
if (deduplicateAcrossServers && serverFilter == null && out.length > 1) {
return _deduplicateEntries(out);
}
return out;
}
const _typeOrder = <String, ({String label, int order})>{
'Movie': (label: '电影', order: 0),
'Series': (label: '剧集', order: 1),
'Episode': (label: '单集', order: 2),
};
const _typeOther = (label: '其它', order: 3);
List<AggregatedGroup> groupEntries(
List<AggregatedEntry> entries,
GroupAxis axis,
DateTime now,
) {
if (axis == GroupAxis.mediaType) {
return _groupByType(entries);
}
return _groupByTime(entries, axis, now);
}
List<AggregatedGroup> _groupByTime(
List<AggregatedEntry> entries,
GroupAxis axis,
DateTime now,
) {
final scheme = axis == GroupAxis.watchedTime
? TimeBucketScheme.watched
: TimeBucketScheme.added;
final buckets =
<String, ({TimeBucket bucket, List<AggregatedEntry> items})>{};
for (final e in entries) {
final b = bucketFor(e.dateFor(axis), now, scheme);
(buckets[b.key] ??= (bucket: b, items: [])).items.add(e);
}
final groups = buckets.values.map((v) {
final items = [...v.items]
..sort((a, b) {
final da = a.dateFor(axis);
final db = b.dateFor(axis);
if (da == null && db == null) return 0;
if (da == null) return 1;
if (db == null) return -1;
return db.compareTo(da);
});
return AggregatedGroup(
key: v.bucket.key,
label: v.bucket.label,
order: v.bucket.order,
entries: items,
);
}).toList()..sort((a, b) => a.order.compareTo(b.order));
return groups;
}
List<AggregatedGroup> _groupByType(List<AggregatedEntry> entries) {
final groups =
<String, ({String label, int order, List<AggregatedEntry> items})>{};
for (final e in entries) {
final meta = _typeOrder[e.item.Type] ?? _typeOther;
final key = e.item.Type ?? 'Other';
(groups[key] ??= (
label: meta.label,
order: meta.order,
items: [],
)).items.add(e);
}
final out = groups.entries.map((kv) {
final items = [...kv.value.items]
..sort((a, b) => (a.item.Name).compareTo(b.item.Name));
return AggregatedGroup(
key: kv.key,
label: kv.value.label,
order: kv.value.order,
entries: items,
);
}).toList()..sort((a, b) => a.order.compareTo(b.order));
return out;
}
Set<String> _candidateKeys(EmbyRawItem item) {
final type = item.Type;
if (type == null || type.isEmpty) return const {};
final ids = normalizedProviderIdsOfItem(item);
if (ids.isEmpty) return const {};
return {for (final e in ids.entries) '$type:${e.key}:${e.value}'};
}
List<AggregatedEntry> _deduplicateEntries(List<AggregatedEntry> entries) {
final uf = _UnionFind(entries.length);
final keyToIndex = <String, int>{};
for (var i = 0; i < entries.length; i++) {
final keys = _candidateKeys(entries[i].item);
for (final k in keys) {
final prev = keyToIndex[k];
if (prev != null) {
uf.union(prev, i);
} else {
keyToIndex[k] = i;
}
}
}
final groups = uf.groups();
final survivors = <AggregatedEntry>[];
for (final group in groups) {
final orderedIndices = [...group]
..sort((leftIndex, rightIndex) {
final leftDate = _lastPlayedDate(entries[leftIndex]);
final rightDate = _lastPlayedDate(entries[rightIndex]);
if (leftDate == null && rightDate == null) {
return leftIndex.compareTo(rightIndex);
}
if (leftDate == null) return 1;
if (rightDate == null) return -1;
final dateComparison = rightDate.compareTo(leftDate);
return dateComparison != 0
? dateComparison
: leftIndex.compareTo(rightIndex);
});
final sourceServerIds = <String>{};
final sources = <AggregatedEntrySource>[];
for (final entryIndex in orderedIndices) {
final entry = entries[entryIndex];
if (!sourceServerIds.add(entry.source.serverId)) continue;
sources.add(
AggregatedEntrySource(item: entry.item, source: entry.source),
);
}
final primarySource = sources.first;
survivors.add(
AggregatedEntry(
item: primarySource.item,
source: primarySource.source,
sources: sources,
),
);
}
return survivors;
}
DateTime? _lastPlayedDate(AggregatedEntry e) =>
e.dateFor(GroupAxis.watchedTime);
class _UnionFind {
final List<int> _parent;
final List<int> _rank;
_UnionFind(int n)
: _parent = List.generate(n, (i) => i),
_rank = List.filled(n, 0);
int find(int x) {
if (_parent[x] != x) _parent[x] = find(_parent[x]);
return _parent[x];
}
void union(int a, int b) {
final ra = find(a), rb = find(b);
if (ra == rb) return;
if (_rank[ra] < _rank[rb]) {
_parent[ra] = rb;
} else if (_rank[ra] > _rank[rb]) {
_parent[rb] = ra;
} else {
_parent[rb] = ra;
_rank[ra]++;
}
}
List<List<int>> groups() {
final map = <int, List<int>>{};
for (var i = 0; i < _parent.length; i++) {
(map[find(i)] ??= []).add(i);
}
return map.values.toList();
}
}
@@ -0,0 +1,259 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/contracts/library.dart';
import '../../providers/di_providers.dart';
import '../../providers/session_provider.dart';
import '../../shared/mappers/media_image_url.dart';
import '../../shared/utils/media_nav.dart';
import '../../shared/utils/time_bucket.dart';
import '../../shared/widgets/app_sliver_header.dart';
import '../../shared/widgets/app_loading_ring.dart';
import '../../shared/widgets/empty_state.dart';
import 'aggregated_grouping.dart';
import 'widgets/aggregated_grouped_grid.dart';
import 'widgets/aggregated_media_card.dart';
import 'widgets/server_filter_bar.dart';
class AggregatedRecentPage extends ConsumerStatefulWidget {
final bool embedded;
const AggregatedRecentPage({super.key, this.embedded = false});
@override
ConsumerState<AggregatedRecentPage> createState() =>
_AggregatedRecentPageState();
}
class _AggregatedRecentPageState extends ConsumerState<AggregatedRecentPage> {
StreamSubscription<GlobalSearchServerResult>? _subscription;
List<GlobalSearchServerResult> _serverResults = [];
bool _loading = false;
bool _switching = false;
final Set<String> _collapsed = {};
String? _serverFilter;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
Future<void> _load() async {
_subscription?.cancel();
_subscription = null;
setState(() {
_loading = true;
_serverResults = [];
});
try {
final uc = await ref.read(aggregatedRecentUseCaseProvider.future);
final stream = uc.executeStream();
_subscription = stream.listen(
(result) {
if (mounted) {
setState(() => _serverResults = [..._serverResults, result]);
}
},
onDone: () {
if (mounted) setState(() => _loading = false);
},
onError: (_) {
if (mounted) setState(() => _loading = false);
},
);
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _onItemTap(
BuildContext context,
GlobalSearchServerResult serverResult,
EmbyRawItem item,
) async {
if (_switching) return;
setState(() => _switching = true);
try {
await goMediaDetailOnServer(
context,
ref,
serverId: serverResult.serverId,
item: item,
);
} finally {
if (mounted) setState(() => _switching = false);
}
}
void _toggle(String key) {
setState(() {
if (!_collapsed.remove(key)) _collapsed.add(key);
});
}
@override
Widget build(BuildContext context) {
ref.listen(sessionProvider, (previous, next) {
final prevIds =
previous?.value?.sessions.map((s) => s.serverId).toSet() ??
<String>{};
final nextIds =
next.value?.sessions.map((s) => s.serverId).toSet() ?? <String>{};
if (prevIds.length == nextIds.length && prevIds.containsAll(nextIds)) {
return;
}
if (_serverFilter != null && !nextIds.contains(_serverFilter)) {
setState(() => _serverFilter = null);
}
_load();
});
if (widget.embedded) {
return Stack(
fit: StackFit.expand,
children: [
CustomScrollView(
slivers: [
SliverOverlapInjector(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
context,
),
),
..._buildContentSlivers(context),
],
),
if (_switching) _switchingOverlay(),
],
);
}
return Stack(
fit: StackFit.expand,
children: [
Scaffold(
extendBodyBehindAppBar: true,
body: CustomScrollView(
slivers: [
AppSliverHeader(
title: '最近添加',
actions: [
IconButton(
onPressed: _loading ? null : _load,
icon: _loading
? const AppLoadingRing(size: 16)
: const Icon(Icons.refresh, size: 18),
tooltip: '刷新',
),
],
),
..._buildContentSlivers(context),
],
),
),
if (_switching) _switchingOverlay(),
],
);
}
Widget _switchingOverlay() => const Positioned.fill(
child: ColoredBox(
color: Color(0x44000000),
child: Center(child: AppLoadingRing()),
),
);
List<Widget> _buildContentSlivers(BuildContext context) {
if (_serverResults.isEmpty && !_loading) {
return const [
SliverFillRemaining(
hasScrollBody: false,
child: EmptyState(
icon: Icons.new_releases_outlined,
title: '暂无最近添加内容',
),
),
];
}
if (_serverResults.isEmpty && _loading) {
return const [
SliverFillRemaining(
hasScrollBody: false,
child: Center(child: AppLoadingRing()),
),
];
}
final now = DateTime.now();
final entries = flatten(
_serverResults,
serverFilter: _serverFilter,
deduplicateAcrossServers: true,
);
final groups = groupEntries(entries, GroupAxis.addedTime, now);
final servers = _serverResults
.map((sr) => (id: sr.serverId, name: sr.serverName))
.toList(growable: false);
return [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: ServerFilterBar(
servers: servers,
selected: _serverFilter,
onSelect: (v) => setState(() => _serverFilter = v),
),
),
),
...buildAggregatedGridSlivers(
groups: groups,
collapsed: _collapsed,
onToggle: _toggle,
cardBuilder: (entry) => _card(context, entry, now),
),
];
}
Widget _card(BuildContext context, AggregatedEntry entry, DateTime now) {
final sr = entry.source;
final item = entry.item;
final subtitle = item.ProductionYear != null
? '${item.ProductionYear}'
: null;
return AggregatedMediaCard(
item: item,
imageUrl: EmbyImageUrl.landscapeCard(
baseUrl: sr.serverUrl,
token: sr.token,
item: item,
maxWidth: 400,
),
imageHeaders: buildEmbyImageHeaders(
ref,
token: sr.token,
userId: sr.userId,
),
title: item.Name,
subtitle: subtitle,
serverName: sr.serverName,
timeLabel: relativeTimeLabel(entry.dateFor(GroupAxis.addedTime), now),
showProgress: false,
onTap: () => _onItemTap(context, sr, item),
);
}
}
@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../shared/theme/app_theme.dart';
import '../../shared/widgets/app_sliver_header.dart';
import '../../shared/widgets/page_background.dart';
import '../../shared/widgets/pill_tab_bar.dart';
import '../favorites/aggregated_favorites_page.dart';
import '../history/history_page.dart';
import 'aggregated_recent_page.dart';
const _kTabHistory = 'history';
const _kTabFavorites = 'favorites';
const _kTabRecent = 'recent';
int _tabIndexFromKey(String? key) => switch (key) {
_kTabFavorites => 1,
_kTabRecent => 2,
_ => 0,
};
String _tabKeyFromIndex(int index) => switch (index) {
1 => _kTabFavorites,
2 => _kTabRecent,
_ => _kTabHistory,
};
class AggregatedViewPage extends ConsumerStatefulWidget {
final String? initialTab;
final bool isCompactMode;
const AggregatedViewPage({
super.key,
this.initialTab,
this.isCompactMode = false,
});
@override
ConsumerState<AggregatedViewPage> createState() => _AggregatedViewPageState();
}
class _AggregatedViewPageState extends ConsumerState<AggregatedViewPage> {
late int _index;
int _historyRefreshSeed = 0;
int _favoritesRefreshSeed = 0;
int _recentRefreshSeed = 0;
String get _storagePrefix => widget.isCompactMode ? 'watch' : 'aggregated';
@override
void initState() {
super.initState();
_index = _tabIndexFromKey(widget.initialTab);
}
@override
void didUpdateWidget(covariant AggregatedViewPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.initialTab != oldWidget.initialTab) {
final next = _tabIndexFromKey(widget.initialTab);
if (next != _index) {
setState(() => _index = next);
}
}
}
void _onTabChanged(int next) {
if (next == _index) return;
setState(() => _index = next);
final key = _tabKeyFromIndex(next);
context.go('/aggregated?tab=$key');
}
Future<void> _refreshActive() async {
setState(() {
switch (_index) {
case 1:
_favoritesRefreshSeed++;
break;
case 2:
_recentRefreshSeed++;
break;
default:
_historyRefreshSeed++;
}
});
await Future<void>.delayed(const Duration(milliseconds: 250));
}
@override
Widget build(BuildContext context) {
final tokens = context.appTokens;
final firstTabLabel = widget.isCompactMode ? '继续观看' : '播放记录';
return Scaffold(
extendBodyBehindAppBar: true,
backgroundColor: Colors.transparent,
body: Stack(
children: [
const Positioned.fill(child: PageBackground()),
RefreshIndicator(
onRefresh: _refreshActive,
child: NestedScrollView(
physics: const AlwaysScrollableScrollPhysics(
parent: BouncingScrollPhysics(),
),
floatHeaderSlivers: false,
headerSliverBuilder: (context, innerBoxIsScrolled) => [
SliverOverlapAbsorber(
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
context,
),
sliver: AppSliverHeader(
toolbarHeight: tokens.titleBarHeight,
automaticallyImplyLeading: false,
titleWidget: _buildTitleRow(firstTabLabel),
),
),
],
body: IndexedStack(
index: _index,
children: [
KeyedSubtree(
key: PageStorageKey(
'${_storagePrefix}_history_$_historyRefreshSeed',
),
child: const HistoryPage(embedded: true),
),
KeyedSubtree(
key: PageStorageKey(
'${_storagePrefix}_favorites_$_favoritesRefreshSeed',
),
child: const AggregatedFavoritesPage(embedded: true),
),
KeyedSubtree(
key: PageStorageKey(
'${_storagePrefix}_recent_$_recentRefreshSeed',
),
child: const AggregatedRecentPage(embedded: true),
),
],
),
),
),
],
),
);
}
Widget _buildTitleRow(String firstTabLabel) {
final tabBar = PillTabBar(
padding: const EdgeInsets.only(left: 8),
tabs: [firstTabLabel, '我的收藏', '最近添加'],
selectedIndex: _index,
onSelect: _onTabChanged,
);
return Align(alignment: Alignment.centerLeft, child: tabBar);
}
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import '../../../shared/layout/adaptive_card_grid.dart';
import '../../../shared/widgets/section_header.dart';
import 'aggregated_media_card.dart';
import '../aggregated_grouping.dart';
List<Widget> buildAggregatedGridSlivers({
required List<AggregatedGroup> groups,
required Set<String> collapsed,
required ValueChanged<String> onToggle,
required Widget Function(AggregatedEntry entry) cardBuilder,
EdgeInsets padding = const EdgeInsets.fromLTRB(24, 4, 24, 24),
}) {
final slivers = <Widget>[];
for (final group in groups) {
final isCollapsed = collapsed.contains(group.key);
slivers.add(
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(left: padding.left, right: padding.right),
child: SectionHeader(
label: group.label,
count: group.entries.length,
collapsible: true,
collapsed: isCollapsed,
onToggle: () => onToggle(group.key),
),
),
),
);
if (!isCollapsed) {
final entries = group.entries;
slivers.add(
SliverPadding(
padding: EdgeInsets.fromLTRB(padding.left, 0, padding.right, 20),
sliver: SliverLayoutBuilder(
builder: (context, constraints) {
final availableWidth = constraints.crossAxisExtent;
final columnCount = cardColumnsFor(
availableWidth,
minCardWidth: kRichCardMinWidth,
);
final gapTotalWidth = kCardGap * (columnCount - 1);
final cardWidth = (availableWidth - gapTotalWidth) / columnCount;
return SliverGrid(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: columnCount,
mainAxisExtent: AggregatedMediaCard.height,
crossAxisSpacing: kCardGap,
mainAxisSpacing: kCardGap,
),
delegate: SliverChildBuilderDelegate(
(context, index) => SizedBox(
width: cardWidth,
child: cardBuilder(entries[index]),
),
childCount: entries.length,
),
);
},
),
),
);
}
}
return slivers;
}
@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
import '../../../core/contracts/library.dart';
import '../../../shared/theme/app_theme.dart';
import '../../../shared/utils/emby_ticks.dart';
import '../../../shared/widgets/app_card.dart';
import '../../../shared/widgets/card_progress_bar.dart';
import '../../../shared/widgets/metadata_chip.dart';
import '../../../shared/widgets/smart_image.dart';
class AggregatedMediaCard extends StatefulWidget {
final EmbyRawItem item;
final String? imageUrl;
final String? fallbackImageUrl;
final Map<String, String>? imageHeaders;
final String title;
final String? subtitle;
final String serverName;
final int serverSourceCount;
final String? timeLabel;
final String? networkName;
final String? networkLogoUrl;
final bool showProgress;
final VoidCallback? onTap;
final VoidCallback? onServerSourcesTap;
final List<FItem>? contextMenuItems;
const AggregatedMediaCard({
super.key,
required this.item,
required this.imageUrl,
required this.title,
required this.serverName,
this.serverSourceCount = 1,
this.fallbackImageUrl,
this.imageHeaders,
this.subtitle,
this.timeLabel,
this.networkName,
this.networkLogoUrl,
this.showProgress = false,
this.onTap,
this.onServerSourcesTap,
this.contextMenuItems,
}) : assert(serverSourceCount > 0);
static const double height = 84;
static const double _thumbWidth = 124;
@override
State<AggregatedMediaCard> createState() => _AggregatedMediaCardState();
}
class _AggregatedMediaCardState extends State<AggregatedMediaCard> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.appTokens;
final radius = tokens.radiusCard;
final progress = widget.showProgress ? playbackProgress(widget.item) : 0.0;
final remaining = widget.showProgress
? formatRemainingLabel(widget.item)
: null;
return AppCard(
onTap: widget.onTap,
contextMenuItems: widget.contextMenuItems,
radius: radius,
padding: EdgeInsets.zero,
onHoverChanged: (h) => setState(() => _hovered = h),
child: SizedBox(
height: AggregatedMediaCard.height,
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_thumbnail(radius, progress),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 10, 8),
child: _info(theme, tokens, remaining),
),
),
],
),
),
);
}
Widget _thumbnail(double radius, double progress) {
return SizedBox(
width: AggregatedMediaCard._thumbWidth,
child: Stack(
fit: StackFit.expand,
children: [
SmartImage(
url: widget.imageUrl,
fallbackUrls: widget.fallbackImageUrl != null
? [widget.fallbackImageUrl!]
: const [],
httpHeaders: widget.imageHeaders,
fit: BoxFit.cover,
borderRadius: 0,
),
if (_hovered)
Container(
color: Colors.black.withValues(alpha: 0.32),
child: const Center(
child: Icon(
Icons.play_arrow_rounded,
size: 30,
color: Colors.white,
),
),
),
if (progress > 0) CardProgressBar(progress: progress),
],
),
);
}
Widget _networkBadge(AppTokens tokens) {
if (widget.networkLogoUrl != null) {
return SizedBox(
height: 16,
child: SmartImage(
url: widget.networkLogoUrl,
fit: BoxFit.contain,
borderRadius: 0,
),
);
}
return MetadataChip(
label: widget.networkName!,
dense: true,
foreground: tokens.mutedForeground,
);
}
Widget _serverBadge() {
final hasMultipleSources = widget.serverSourceCount > 1;
if (!hasMultipleSources || widget.onServerSourcesTap == null) {
return MetadataChip(label: widget.serverName, dense: true);
}
final label = '${widget.serverName} ${widget.serverSourceCount}';
return Tooltip(
message: '选择服务器并查看各自的播放进度',
child: Semantics(
button: true,
label: '$label,选择其他服务器',
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: widget.onServerSourcesTap,
child: MetadataChip(
label: label,
icon: Icons.keyboard_arrow_down_rounded,
dense: true,
),
),
),
),
);
}
Widget _info(ThemeData theme, AppTokens tokens, String? remaining) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
if (widget.subtitle != null) ...[
const SizedBox(height: 2),
Text(
widget.subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: tokens.mutedForeground,
),
),
],
],
),
Row(
children: [
_serverBadge(),
if (widget.networkName != null &&
widget.networkName!.isNotEmpty) ...[
const SizedBox(width: 6),
_networkBadge(tokens),
],
if (remaining != null) ...[
const SizedBox(width: 8),
Flexible(
child: Text(
remaining,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: tokens.mutedForeground,
fontWeight: FontWeight.w600,
),
),
),
] else if (widget.timeLabel != null) ...[
const SizedBox(width: 8),
Flexible(
child: Text(
widget.timeLabel!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.labelSmall?.copyWith(
color: tokens.mutedForeground,
),
),
),
],
],
),
],
);
}
}
@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:forui/forui.dart';
class ServerFilterBar extends StatelessWidget {
final List<({String id, String name})> servers;
final String? selected;
final ValueChanged<String?> onSelect;
final EdgeInsetsGeometry padding;
const ServerFilterBar({
super.key,
required this.servers,
required this.selected,
required this.onSelect,
this.padding = const EdgeInsets.fromLTRB(24, 0, 24, 12),
});
@override
Widget build(BuildContext context) {
if (servers.length < 2) return const SizedBox.shrink();
return Padding(
padding: padding,
child: Align(
alignment: Alignment.centerLeft,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_chip(label: '全部', value: null),
for (final s in servers) _chip(label: s.name, value: s.id),
],
),
),
),
);
}
Widget _chip({required String label, required String? value}) {
final active = selected == value;
final child = Text(label);
return Padding(
padding: const EdgeInsets.only(right: 8),
child: FButton(
variant: active ? FButtonVariant.primary : FButtonVariant.ghost,
onPress: () => onSelect(value),
child: child,
),
);
}
}