444 lines
14 KiB
Dart
444 lines
14 KiB
Dart
// Author: fruit.com.cn
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../../../core/formatting/count_format.dart';
|
|
import '../../../core/theme/app_colors.dart';
|
|
import '../../../core/theme/app_dimens.dart';
|
|
import '../../../l10n/app_localizations.dart';
|
|
|
|
enum HomePrimaryTab {
|
|
featured,
|
|
newReleases,
|
|
categories,
|
|
ranking,
|
|
costume,
|
|
community,
|
|
}
|
|
|
|
class HomeGridCategoryShortcut {
|
|
const HomeGridCategoryShortcut({
|
|
required this.label,
|
|
required this.categoryName,
|
|
required this.tab,
|
|
});
|
|
|
|
final String label;
|
|
final String categoryName;
|
|
final HomePrimaryTab tab;
|
|
}
|
|
|
|
class HomeGridTagOption {
|
|
const HomeGridTagOption({required this.id, required this.label});
|
|
|
|
final String id;
|
|
final String label;
|
|
}
|
|
|
|
class HomeGridCallbacks {
|
|
const HomeGridCallbacks({
|
|
required this.onSearch,
|
|
required this.onOpenVip,
|
|
required this.onOpenWallet,
|
|
required this.onOpenFilter,
|
|
});
|
|
|
|
final VoidCallback onSearch;
|
|
final VoidCallback onOpenVip;
|
|
final VoidCallback onOpenWallet;
|
|
final VoidCallback onOpenFilter;
|
|
}
|
|
|
|
class HomeGridHeader extends StatelessWidget {
|
|
const HomeGridHeader({
|
|
super.key,
|
|
required this.callbacks,
|
|
required this.categories,
|
|
required this.tags,
|
|
required this.selectedPrimary,
|
|
required this.selectedTag,
|
|
required this.onPrimarySelected,
|
|
required this.onTagSelected,
|
|
this.walletBadge,
|
|
});
|
|
|
|
final HomeGridCallbacks callbacks;
|
|
final List<HomeGridCategoryShortcut> categories;
|
|
final List<HomeGridTagOption> tags;
|
|
final HomePrimaryTab selectedPrimary;
|
|
final String? selectedTag;
|
|
final ValueChanged<HomePrimaryTab> onPrimarySelected;
|
|
final ValueChanged<String?> onTagSelected;
|
|
final int? walletBadge;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return Column(
|
|
key: const Key('home-grid-header'),
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: [
|
|
_searchRow(l10n),
|
|
_primaryRow(l10n),
|
|
if (selectedPrimary == HomePrimaryTab.featured)
|
|
_tagRow(l10n)
|
|
else
|
|
const SizedBox(height: AppDimens.homeSecondaryGridGap),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _searchRow(AppLocalizations l10n) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppDimens.feedPageHorizontal,
|
|
AppDimens.homeSearchTopGap,
|
|
AppDimens.feedPageHorizontal,
|
|
AppDimens.homeSearchPrimaryGap,
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: InkWell(
|
|
key: const Key('home-search-entry'),
|
|
borderRadius: BorderRadius.circular(AppDimens.searchRadius),
|
|
onTap: callbacks.onSearch,
|
|
child: Container(
|
|
height: AppDimens.searchHeight,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppDimens.spaceMd,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.contentSurface,
|
|
borderRadius: BorderRadius.circular(AppDimens.searchRadius),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.search_rounded,
|
|
size: AppDimens.homeHeaderIcon,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
const SizedBox(width: AppDimens.spaceSm),
|
|
Expanded(
|
|
child: Text(
|
|
l10n.homeSearchHint,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: AppColors.textSecondary,
|
|
fontSize: AppDimens.searchPlaceholderFont,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: AppDimens.spaceSm),
|
|
_headerAction(
|
|
key: const Key('home-vip-entry'),
|
|
frameKey: const Key('home-vip-icon-frame'),
|
|
imageKey: const Key('home-vip-image'),
|
|
assetPath: 'assets/icon/crown.png',
|
|
fallbackIcon: Icons.workspace_premium_outlined,
|
|
color: AppColors.homeCrownGold,
|
|
tooltip: l10n.vipMembership,
|
|
onTap: callbacks.onOpenVip,
|
|
),
|
|
const SizedBox(width: AppDimens.homeActionGap),
|
|
_headerAction(
|
|
key: const Key('home-wallet-entry'),
|
|
frameKey: const Key('home-wallet-icon-frame'),
|
|
imageKey: const Key('home-wallet-image'),
|
|
assetPath: 'assets/icon/gift.png',
|
|
fallbackIcon: Icons.card_giftcard_rounded,
|
|
color: AppColors.homeGiftPink,
|
|
tooltip: l10n.wallet,
|
|
onTap: callbacks.onOpenWallet,
|
|
badge: walletBadge,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _headerAction({
|
|
required Key key,
|
|
required Key frameKey,
|
|
required Key imageKey,
|
|
required String assetPath,
|
|
required IconData fallbackIcon,
|
|
required Color color,
|
|
required String tooltip,
|
|
required VoidCallback onTap,
|
|
int? badge,
|
|
}) {
|
|
return SizedBox.square(
|
|
dimension: AppDimens.homeActionSlot,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Positioned.fill(
|
|
child: Tooltip(
|
|
message: tooltip,
|
|
child: Material(
|
|
color: AppColors.transparent,
|
|
child: InkWell(
|
|
key: key,
|
|
onTap: onTap,
|
|
child: Center(
|
|
child: SizedBox.square(
|
|
key: frameKey,
|
|
dimension: AppDimens.homeActionIcon,
|
|
child: Image.asset(
|
|
assetPath,
|
|
key: imageKey,
|
|
width: AppDimens.homeActionIcon,
|
|
height: AppDimens.homeActionIcon,
|
|
fit: BoxFit.contain,
|
|
errorBuilder: (_, _, _) => Icon(
|
|
fallbackIcon,
|
|
size: AppDimens.homeActionIcon,
|
|
color: color,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (badge != null && badge > 0)
|
|
Positioned(
|
|
key: const Key('home-wallet-badge'),
|
|
top: 0,
|
|
right: 0,
|
|
child: Container(
|
|
constraints: const BoxConstraints(minWidth: 20, minHeight: 16),
|
|
padding: const EdgeInsets.symmetric(horizontal: 5),
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.badgeHot,
|
|
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
|
),
|
|
child: Text(
|
|
formatInteractionCount(badge),
|
|
style: const TextStyle(
|
|
color: AppColors.navSelected,
|
|
fontSize: AppDimens.cardBadgeFont,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _primaryRow(AppLocalizations l10n) {
|
|
return SingleChildScrollView(
|
|
key: const Key('home-primary-sections'),
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppDimens.feedPageHorizontal,
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: [
|
|
_primaryItem(
|
|
key: const Key('home-section-featured'),
|
|
label: l10n.homeSectionFeatured,
|
|
tab: HomePrimaryTab.featured,
|
|
),
|
|
_primaryItem(
|
|
key: const Key('home-section-new'),
|
|
label: l10n.homeSectionNew,
|
|
tab: HomePrimaryTab.newReleases,
|
|
),
|
|
_primaryItem(
|
|
key: const Key('home-section-categories'),
|
|
label: l10n.homeSectionCategories,
|
|
tab: HomePrimaryTab.categories,
|
|
),
|
|
_primaryItem(
|
|
key: const Key('home-section-ranking'),
|
|
label: l10n.homeSectionRanking,
|
|
tab: HomePrimaryTab.ranking,
|
|
),
|
|
for (final category in categories)
|
|
_primaryItem(
|
|
key: Key(
|
|
category.categoryName.isEmpty
|
|
? 'home-section-${category.tab.name}'
|
|
: 'home-section-category-${category.categoryName}',
|
|
),
|
|
label: category.label,
|
|
tab: category.tab,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _primaryItem({
|
|
required Key key,
|
|
required String label,
|
|
required HomePrimaryTab tab,
|
|
}) {
|
|
final selected = selectedPrimary == tab;
|
|
return InkWell(
|
|
key: key,
|
|
onTap: () => onPrimarySelected(tab),
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(right: AppDimens.homePrimaryCategoryGap),
|
|
child: SizedBox(
|
|
height: AppDimens.homePrimaryRowHeight,
|
|
child: Align(
|
|
alignment: Alignment.bottomLeft,
|
|
child: AnimatedDefaultTextStyle(
|
|
key: Key('home-primary-label-${tab.name}'),
|
|
duration: AppDimens.homePrimaryTabAnimationDuration,
|
|
curve: Curves.easeOut,
|
|
style: TextStyle(
|
|
color: selected
|
|
? AppColors.navSelected
|
|
: AppColors.textTabInactive,
|
|
fontSize: selected
|
|
? AppDimens.homePrimaryTabSelectedFont
|
|
: AppDimens.homePrimaryTabFont,
|
|
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
|
|
letterSpacing: 0,
|
|
),
|
|
child: Text(label, textAlign: TextAlign.left),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _tagRow(AppLocalizations l10n) {
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppDimens.feedPageHorizontal,
|
|
AppDimens.homePrimarySecondaryGap,
|
|
AppDimens.feedPageHorizontal,
|
|
AppDimens.homeSecondaryGridGap,
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
key: const Key('home-secondary-tags'),
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
children: [
|
|
for (final tag in tags) ...[
|
|
_tagChip(tag),
|
|
const SizedBox(width: AppDimens.homeSecondaryTagGap),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: AppDimens.homeSecondaryFilterGap),
|
|
Tooltip(
|
|
message: l10n.filter,
|
|
child: SizedBox(
|
|
width: AppDimens.tapTarget,
|
|
height: AppDimens.homeSecondaryFilterVisualSize,
|
|
child: Material(
|
|
color: AppColors.transparent,
|
|
child: InkWell(
|
|
key: const Key('home-grid-filter'),
|
|
onTap: callbacks.onOpenFilter,
|
|
child: Center(
|
|
child: SizedBox.square(
|
|
key: const Key('home-secondary-filter-visual'),
|
|
dimension: AppDimens.homeSecondaryFilterVisualSize,
|
|
child: const Center(
|
|
child: CustomPaint(
|
|
key: Key('home-secondary-filter-icon'),
|
|
size: Size.square(AppDimens.homeSecondaryFilterIcon),
|
|
painter: _ThreeLineFilterPainter(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _tagChip(HomeGridTagOption tag) {
|
|
final selected = selectedTag == tag.id;
|
|
final label = Text(
|
|
tag.label,
|
|
style: TextStyle(
|
|
color: selected ? AppColors.navSelected : AppColors.textTertiary,
|
|
fontSize: AppDimens.homeSecondaryTagFont,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0,
|
|
),
|
|
);
|
|
return InkWell(
|
|
key: Key('home-tag-${tag.id}'),
|
|
borderRadius: BorderRadius.circular(AppDimens.pillRadius),
|
|
onTap: () => onTagSelected(selected ? null : tag.id),
|
|
child: AnimatedContainer(
|
|
key: Key('home-tag-animated-${tag.id}'),
|
|
duration: AppDimens.homeSecondaryTagAnimationDuration,
|
|
curve: Curves.easeOut,
|
|
height: AppDimens.homeSecondaryTagHeight,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppDimens.homeSecondaryTagHorizontalPadding,
|
|
),
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: selected ? AppColors.contentSurface : AppColors.transparent,
|
|
borderRadius: BorderRadius.circular(AppDimens.homeSecondaryTagRadius),
|
|
),
|
|
child: label,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ThreeLineFilterPainter extends CustomPainter {
|
|
const _ThreeLineFilterPainter();
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = AppColors.textFilterIcon
|
|
..strokeWidth = AppDimens.homeSecondaryFilterStroke
|
|
..strokeCap = StrokeCap.butt;
|
|
final positions = [
|
|
size.height * 0.25,
|
|
size.height * 0.5,
|
|
size.height * 0.75,
|
|
];
|
|
for (final y in positions) {
|
|
canvas.drawLine(Offset(0, y), Offset(size.width, y), paint);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(_ThreeLineFilterPainter oldDelegate) => false;
|
|
}
|