yumi-flutter/lib/ui_kit/widgets/badge/sc_user_badge_strip.dart

425 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:yumi/shared/business_logic/models/res/login_res.dart';
import 'package:yumi/shared/business_logic/models/res/sc_user_badge_res.dart';
import 'package:yumi/shared/data_sources/sources/local/user_manager.dart';
import 'package:yumi/shared/data_sources/sources/repositories/sc_user_repository_impl.dart';
import 'package:yumi/ui_kit/components/sc_compontent.dart';
enum SCUserBadgeDisplayScope { short, long, all }
class SCUserBadgeStrip extends StatefulWidget {
const SCUserBadgeStrip({
super.key,
this.userId,
this.fallbackBadges = const <WearBadge>[],
this.displayScope = SCUserBadgeDisplayScope.short,
this.height,
this.badgeHeight,
this.longBadgeWidth,
this.spacing,
this.maxBadges,
this.wrap = false,
this.maxWrapRows,
this.scrollLastWrapRow = false,
this.reserveSpace = false,
this.alignment = WrapAlignment.center,
});
final String? userId;
final List<WearBadge> fallbackBadges;
final SCUserBadgeDisplayScope displayScope;
final double? height;
final double? badgeHeight;
final double? longBadgeWidth;
final double? spacing;
final int? maxBadges;
final bool wrap;
final int? maxWrapRows;
final bool scrollLastWrapRow;
final bool reserveSpace;
final WrapAlignment alignment;
@override
State<SCUserBadgeStrip> createState() => _SCUserBadgeStripState();
}
class _SCUserBadgeStripState extends State<SCUserBadgeStrip> {
static const Duration _cacheTtl = Duration(minutes: 1);
static final Map<String, _BadgeFutureCacheEntry> _badgeFutures = {};
Future<SCUserBadgeRes>? _future;
@override
void initState() {
super.initState();
_future = _resolveFuture();
}
@override
void didUpdateWidget(covariant SCUserBadgeStrip oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId) {
_future = _resolveFuture();
}
}
Future<SCUserBadgeRes>? _resolveFuture() {
final userId = _normalizedUserId;
if (userId.isEmpty) {
return null;
}
final currentUserId =
AccountStorage().getCurrentUser()?.userProfile?.id?.trim() ?? "";
final isCurrentUser = currentUserId.isNotEmpty && userId == currentUserId;
final cacheKey = isCurrentUser ? "current:$userId" : "other:$userId";
final now = DateTime.now();
_badgeFutures.removeWhere(
(_, entry) => now.difference(entry.createdAt) >= _cacheTtl,
);
final cacheEntry = _badgeFutures[cacheKey];
if (cacheEntry != null &&
now.difference(cacheEntry.createdAt) < _cacheTtl) {
return cacheEntry.future;
}
final future = () async {
try {
return isCurrentUser
? await SCAccountRepository().currentUserBadges()
: await SCAccountRepository().otherUserBadges(userId);
} catch (_) {
_badgeFutures.remove(cacheKey);
return SCUserBadgeRes.empty(userId: userId);
}
}();
_badgeFutures[cacheKey] = _BadgeFutureCacheEntry(future, now);
return future;
}
String get _normalizedUserId => widget.userId?.trim() ?? "";
@override
Widget build(BuildContext context) {
final stripHeight = widget.height ?? 25.w;
final future = _future;
if (future == null) {
return _buildBadges(_fallbackBadges, stripHeight);
}
return FutureBuilder<SCUserBadgeRes>(
future: future,
builder: (context, snapshot) {
if (snapshot.hasData) {
return _buildBadges(_remoteBadges(snapshot.data), stripHeight);
}
return _buildBadges(_fallbackBadges, stripHeight);
},
);
}
List<WearBadge> get _fallbackBadges {
return widget.fallbackBadges
.where(_matchesDisplayScope)
.where((badge) => _badgeUrl(badge).isNotEmpty)
.toList();
}
List<WearBadge> _remoteBadges(SCUserBadgeRes? data) {
if (data == null) {
return const <WearBadge>[];
}
switch (widget.displayScope) {
case SCUserBadgeDisplayScope.long:
return data.longDisplayBadges;
case SCUserBadgeDisplayScope.all:
return data.allBadges
.where((badge) => _badgeUrl(badge).isNotEmpty)
.toList();
case SCUserBadgeDisplayScope.short:
return data.shortDisplayBadges;
}
}
bool _matchesDisplayScope(WearBadge badge) {
switch (widget.displayScope) {
case SCUserBadgeDisplayScope.long:
return SCUserBadgeRes.isLongDisplayBadge(badge);
case SCUserBadgeDisplayScope.all:
return true;
case SCUserBadgeDisplayScope.short:
return SCUserBadgeRes.isShortDisplayBadge(badge);
}
}
Widget _buildBadges(List<WearBadge> rawBadges, double stripHeight) {
final badges =
widget.maxBadges == null
? rawBadges
: rawBadges.take(widget.maxBadges!).toList();
if (badges.isEmpty) {
return widget.reserveSpace
? SizedBox(height: stripHeight)
: const SizedBox.shrink();
}
final badgeHeight = widget.badgeHeight ?? stripHeight;
final spacing = widget.spacing ?? 5.w;
final children =
badges
.map((badge) => _buildBadge(badge, badgeHeight: badgeHeight))
.toList();
if (widget.wrap) {
if (widget.maxWrapRows != null) {
return _buildLimitedWrapBadges(
badges,
stripHeight: stripHeight,
badgeHeight: badgeHeight,
spacing: spacing,
);
}
return SizedBox(
height: stripHeight,
child: Center(
child: Wrap(
alignment: widget.alignment,
spacing: spacing,
runSpacing: 5.w,
children: children,
),
),
);
}
return SizedBox(
height: stripHeight,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(children: _withSpacing(children, spacing)),
),
);
}
Widget _buildLimitedWrapBadges(
List<WearBadge> badges, {
required double stripHeight,
required double badgeHeight,
required double spacing,
}) {
final maxRows = widget.maxWrapRows ?? 1;
if (maxRows <= 1) {
return _buildScrollableBadgeRow(
badges,
stripHeight: stripHeight,
badgeHeight: badgeHeight,
spacing: spacing,
);
}
return SizedBox(
height: stripHeight,
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth =
constraints.maxWidth.isFinite
? constraints.maxWidth
: _badgesTotalWidth(badges, badgeHeight, spacing);
final widestBadge = badges
.map((badge) => _badgeWidth(badge, badgeHeight))
.fold<double>(
0,
(current, width) => width > current ? width : current,
);
final perRow =
widestBadge <= 0
? badges.length
: ((maxWidth + spacing) / (widestBadge + spacing))
.floor()
.clamp(1, badges.length);
final firstRows = maxRows - 1;
final fixedCount = (perRow * firstRows).clamp(0, badges.length);
final fixedBadges = badges.take(fixedCount).toList();
final lastRowBadges = badges.skip(fixedCount).toList();
final rows = <Widget>[];
for (var start = 0; start < fixedBadges.length; start += perRow) {
final end =
start + perRow > fixedBadges.length
? fixedBadges.length
: start + perRow;
rows.add(
_buildBadgeRow(
fixedBadges.sublist(start, end),
badgeHeight: badgeHeight,
spacing: spacing,
),
);
}
if (lastRowBadges.isNotEmpty) {
rows.add(
widget.scrollLastWrapRow
? _buildScrollableBadgeRow(
lastRowBadges,
stripHeight: badgeHeight,
badgeHeight: badgeHeight,
spacing: spacing,
)
: _buildBadgeRow(
lastRowBadges.take(perRow).toList(),
badgeHeight: badgeHeight,
spacing: spacing,
),
);
}
if (rows.isEmpty) {
return const SizedBox.shrink();
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: _withVerticalSpacing(rows, 5.w),
);
},
),
);
}
Widget _buildBadgeRow(
List<WearBadge> badges, {
required double badgeHeight,
required double spacing,
}) {
return SizedBox(
height: badgeHeight,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: _rowAlignment,
children: _withSpacing(
badges
.map((badge) => _buildBadge(badge, badgeHeight: badgeHeight))
.toList(),
spacing,
),
),
);
}
Widget _buildScrollableBadgeRow(
List<WearBadge> badges, {
required double stripHeight,
required double badgeHeight,
required double spacing,
}) {
return SizedBox(
height: stripHeight,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
child: Row(
mainAxisSize: MainAxisSize.min,
children: _withSpacing(
badges
.map((badge) => _buildBadge(badge, badgeHeight: badgeHeight))
.toList(),
spacing,
),
),
),
);
}
Widget _buildBadge(WearBadge badge, {required double badgeHeight}) {
final width = _badgeWidth(badge, badgeHeight);
return netImage(
url: _badgeUrl(badge),
width: width,
height: badgeHeight,
fit: BoxFit.contain,
noDefaultImg: true,
loadingWidget: const SizedBox.shrink(),
errorWidget: const SizedBox.shrink(),
);
}
double _badgeWidth(WearBadge badge, double badgeHeight) {
final type = badge.type?.trim().toUpperCase() ?? "";
final sourceType = badge.sourceType?.trim().toUpperCase() ?? "";
final isLongBadge = type == "LONG" || type == "VIP" || sourceType == "VIP";
return isLongBadge
? (widget.longBadgeWidth ?? badgeHeight * 2.2)
: badgeHeight;
}
double _badgesTotalWidth(
List<WearBadge> badges,
double badgeHeight,
double spacing,
) {
if (badges.isEmpty) {
return 0;
}
final badgeWidth = badges.fold<double>(
0,
(sum, badge) => sum + _badgeWidth(badge, badgeHeight),
);
return badgeWidth + spacing * (badges.length - 1);
}
MainAxisAlignment get _rowAlignment {
switch (widget.alignment) {
case WrapAlignment.start:
return MainAxisAlignment.start;
case WrapAlignment.end:
return MainAxisAlignment.end;
case WrapAlignment.spaceBetween:
return MainAxisAlignment.spaceBetween;
case WrapAlignment.spaceAround:
return MainAxisAlignment.spaceAround;
case WrapAlignment.spaceEvenly:
return MainAxisAlignment.spaceEvenly;
case WrapAlignment.center:
return MainAxisAlignment.center;
}
}
String _badgeUrl(WearBadge badge) {
return (badge.selectUrl ?? "").trim();
}
List<Widget> _withSpacing(List<Widget> children, double spacing) {
if (children.length < 2) {
return children;
}
final spaced = <Widget>[];
for (var i = 0; i < children.length; i++) {
if (i > 0) {
spaced.add(SizedBox(width: spacing));
}
spaced.add(children[i]);
}
return spaced;
}
List<Widget> _withVerticalSpacing(List<Widget> children, double spacing) {
if (children.length < 2) {
return children;
}
final spaced = <Widget>[];
for (var i = 0; i < children.length; i++) {
if (i > 0) {
spaced.add(SizedBox(height: spacing));
}
spaced.add(children[i]);
}
return spaced;
}
}
class _BadgeFutureCacheEntry {
const _BadgeFutureCacheEntry(this.future, this.createdAt);
final Future<SCUserBadgeRes> future;
final DateTime createdAt;
}