show member collection top

Signed-off-by: dom <githubaccount56556@proton.me>
This commit is contained in:
dom
2026-03-10 18:21:36 +08:00
parent 9fef3284db
commit b8098fe067
9 changed files with 446 additions and 77 deletions

View File

@@ -1,15 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show RenderProxyBox;
import 'package:flutter/rendering.dart' show RenderProxyBox, BoxHitTestResult;
class CustomHeightWidget extends SingleChildRenderObjectWidget {
const CustomHeightWidget({
super.key,
required this.height,
this.height,
this.offset = .zero,
required super.child,
required Widget super.child,
});
final double height;
final double? height;
final Offset offset;
@@ -34,14 +34,14 @@ class CustomHeightWidget extends SingleChildRenderObjectWidget {
class RenderCustomHeightWidget extends RenderProxyBox {
RenderCustomHeightWidget({
required double height,
double? height,
required Offset offset,
}) : _height = height,
_offset = offset;
double _height;
double get height => _height;
set height(double value) {
double? _height;
double? get height => _height;
set height(double? value) {
if (_height == value) return;
_height = value;
markNeedsLayout();
@@ -57,12 +57,40 @@ class RenderCustomHeightWidget extends RenderProxyBox {
@override
void performLayout() {
child!.layout(constraints);
size = constraints.constrainDimensions(constraints.maxWidth, height);
if (height != null) {
child!.layout(constraints.copyWith(maxHeight: .infinity));
size = constraints.constrainDimensions(constraints.maxWidth, height!);
} else {
child!.layout(
constraints.copyWith(maxHeight: .infinity),
parentUsesSize: true,
);
size = constraints.constrainDimensions(
constraints.maxWidth,
child!.size.height,
);
}
}
@override
void paint(PaintingContext context, Offset offset) {
context.paintChild(child!, offset + _offset);
}
@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
return result.addWithPaintOffset(
offset: _offset,
position: position,
hitTest: (BoxHitTestResult result, Offset transformed) {
assert(transformed == position - _offset);
return child!.hitTest(result, position: transformed);
},
);
}
@override
void applyPaintTransform(covariant RenderObject child, Matrix4 transform) {
transform.translateByDouble(_offset.dx, _offset.dy, 0.0, 1.0);
}
}

View File

@@ -17,6 +17,7 @@
import 'dart:math' as math;
import 'package:PiliPlus/common/widgets/custom_height_widget.dart';
import 'package:PiliPlus/common/widgets/dynamic_sliver_app_bar/rendering/sliver_persistent_header.dart';
import 'package:PiliPlus/common/widgets/dynamic_sliver_app_bar/sliver_persistent_header.dart';
import 'package:PiliPlus/common/widgets/only_layout_widget.dart'
@@ -24,6 +25,7 @@ import 'package:PiliPlus/common/widgets/only_layout_widget.dart'
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'
hide SliverPersistentHeader, SliverPersistentHeaderDelegate;
import 'package:flutter/rendering.dart' show RenderOpacity, OpacityLayer;
import 'package:flutter/services.dart';
/// ref [SliverAppBar]
@@ -133,11 +135,9 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate {
title: effectiveTitle,
actions: actions,
automaticallyImplyActions: automaticallyImplyActions,
flexibleSpace: maxExtent == .infinity
? flexibleSpace
: IgnorePointer(
flexibleSpace: IgnorePointer(
ignoring: isScrolledUnder,
child: FlexibleSpaceBar(background: flexibleSpace),
child: DynamicFlexibleSpaceBar(background: flexibleSpace),
),
bottom: bottom,
elevation: isScrolledUnder ? elevation : 0.0,
@@ -350,3 +350,148 @@ class _DynamicSliverAppBarState extends State<DynamicSliverAppBar> {
);
}
}
/// ref [FlexibleSpaceBar]
class DynamicFlexibleSpaceBar extends StatefulWidget {
const DynamicFlexibleSpaceBar({
super.key,
required this.background,
this.collapseMode = CollapseMode.parallax,
});
final Widget background;
final CollapseMode collapseMode;
@override
State<DynamicFlexibleSpaceBar> createState() =>
_DynamicFlexibleSpaceBarState();
}
class _DynamicFlexibleSpaceBarState extends State<DynamicFlexibleSpaceBar> {
double _getCollapsePadding(double t, FlexibleSpaceBarSettings settings) {
switch (widget.collapseMode) {
case CollapseMode.pin:
return -(settings.maxExtent - settings.currentExtent);
case CollapseMode.none:
return 0.0;
case CollapseMode.parallax:
final double deltaExtent = settings.maxExtent - settings.minExtent;
return -Tween<double>(begin: 0.0, end: deltaExtent / 4.0).transform(t);
}
}
@override
Widget build(BuildContext context) {
final FlexibleSpaceBarSettings settings = context
.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>()!;
double? height;
final double opacity;
final double topPadding;
if (settings.maxExtent == .infinity) {
opacity = 1.0;
topPadding = 0.0;
} else {
final double deltaExtent = settings.maxExtent - settings.minExtent;
// 0.0 -> Expanded
// 1.0 -> Collapsed to toolbar
final double t = clampDouble(
1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent,
0.0,
1.0,
);
final double fadeStart = math.max(
0.0,
1.0 - kToolbarHeight / deltaExtent,
);
const fadeEnd = 1.0;
assert(fadeStart <= fadeEnd);
// If the min and max extent are the same, the app bar cannot collapse
// and the content should be visible, so opacity = 1.
opacity = settings.maxExtent == settings.minExtent
? 1.0
: 1.0 - Interval(fadeStart, fadeEnd).transform(t);
topPadding = _getCollapsePadding(t, settings);
}
return ClipRect(
child: CustomHeightWidget(
height: height,
offset: Offset(0.0, topPadding),
child: _FlexibleSpaceHeaderOpacity(
// IOS is relying on this semantics node to correctly traverse
// through the app bar when it is collapsed.
alwaysIncludeSemantics: true,
opacity: opacity,
child: widget.background,
),
),
);
}
}
/// [_FlexibleSpaceHeaderOpacity]
class _FlexibleSpaceHeaderOpacity extends SingleChildRenderObjectWidget {
const _FlexibleSpaceHeaderOpacity({
required this.opacity,
required super.child,
required this.alwaysIncludeSemantics,
});
final double opacity;
final bool alwaysIncludeSemantics;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderFlexibleSpaceHeaderOpacity(
opacity: opacity,
alwaysIncludeSemantics: alwaysIncludeSemantics,
);
}
@override
void updateRenderObject(
BuildContext context,
covariant _RenderFlexibleSpaceHeaderOpacity renderObject,
) {
renderObject
..alwaysIncludeSemantics = alwaysIncludeSemantics
..opacity = opacity;
}
}
class _RenderFlexibleSpaceHeaderOpacity extends RenderOpacity {
_RenderFlexibleSpaceHeaderOpacity({
super.opacity,
super.alwaysIncludeSemantics,
});
@override
bool get isRepaintBoundary => false;
@override
void paint(PaintingContext context, Offset offset) {
if (child == null) {
return;
}
if ((opacity * 255).roundToDouble() <= 0) {
layer = null;
return;
}
assert(needsCompositing);
layer = context.pushOpacity(
offset,
(opacity * 255).round(),
super.paint,
oldLayer: layer as OpacityLayer?,
);
assert(() {
layer!.debugCreator = debugCreator;
return true;
}());
}
}

View File

@@ -54,6 +54,7 @@ class GalleryViewer extends StatefulWidget {
required this.quality,
required this.sources,
this.initIndex = 0,
this.onPageChanged,
});
final double minScale;
@@ -61,6 +62,7 @@ class GalleryViewer extends StatefulWidget {
final int quality;
final List<SourceModel> sources;
final int initIndex;
final ValueChanged<int>? onPageChanged;
@override
State<GalleryViewer> createState() => _GalleryViewerState();
@@ -346,6 +348,7 @@ class _GalleryViewerState extends State<GalleryViewer>
_player?.pause();
_playIfNeeded(widget.sources[index]);
_currIndex.value = index;
widget.onPageChanged?.call(index);
}
late final ValueChanged<int>? _onChangePage = widget.sources.length == 1

View File

@@ -15,11 +15,13 @@ class PendantAvatar extends StatelessWidget {
final String? garbPendantImage;
final int? roomId;
final VoidCallback? onTap;
final bool isMemberAvatar;
const PendantAvatar({
super.key,
required this.avatar,
this.size = 80,
required this.size,
this.isMemberAvatar = false,
double? badgeSize,
bool isVip = false,
int? officialType,
@@ -42,13 +44,12 @@ class PendantAvatar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isMemberAvatar = size == 80;
Widget? pendant;
if (showDynDecorate && !garbPendantImage.isNullOrEmpty) {
final pendantSize = size * 1.75;
pendant = Positioned(
// -(size * 1.75 - size) / 2
top: -0.375 * size + (size == 80 ? 2 : 0),
top: -0.375 * size + (isMemberAvatar ? 2 : 0),
child: IgnorePointer(
child: NetworkImgLayer(
type: .emote,

View File

@@ -1,9 +1,40 @@
import 'package:PiliPlus/utils/parse_string.dart';
class Top {
dynamic result;
List<TopImage>? imgUrls;
Top({this.result});
Top({this.imgUrls});
factory Top.fromJson(Map<String, dynamic> json) => Top(
result: json['result'] as dynamic,
);
Top.fromJson(Map<String, dynamic> json) {
try {
final list = json['result'] as List<dynamic>?;
if (list != null && list.isNotEmpty) {
imgUrls = list.map((e) => TopImage.fromJson(e)).toList();
}
} catch (_) {}
}
}
class TopImage {
late final String cover;
late final double dy;
TopImage.fromJson(Map<String, dynamic> json) {
cover =
noneNullOrEmptyString(json['item']?['image']?['default_image']) ??
json['cover'];
try {
final Map image = json['item']['image'] ?? json['item']['animation'];
final num halfHeight = (image['height'] as num) / 2;
final List<num> location = (image['location'] as String)
.split('-')
.map(num.parse)
.toList();
final start = location[1];
final end = location[2];
dy = (start + (end - start) / 2 - halfHeight) / halfHeight;
} catch (_) {
dy = 0.0;
}
}
}

View File

@@ -39,6 +39,8 @@ class _MemberPageState extends State<MemberPage> {
late final int _mid;
late final String _heroTag;
late final MemberController _userController;
PageController? _headerController;
PageController get headerController => _headerController ??= PageController();
@override
void initState() {
@@ -51,6 +53,13 @@ class _MemberPageState extends State<MemberPage> {
);
}
@override
void dispose() {
_headerController?.dispose();
_headerController = null;
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context).colorScheme;
@@ -358,6 +367,7 @@ class _MemberPageState extends State<MemberPage> {
onFollow: () => _userController.onFollow(context),
live: _userController.live,
silence: _userController.silence,
headerControllerBuilder: () => headerController,
),
),
);

View File

@@ -23,8 +23,9 @@ import 'package:flutter/rendering.dart' show BoxHitTestResult, BoxParentData;
const double kHeaderHeight = 135.0;
const double kAvatarSize = 80.0;
const double kPendantAvatarSize = 70.0;
const double _kAvatarLeftPadding = 20.0;
const double _kAvatarTopPadding = 110.0;
const double _kAvatarTopPadding = 115.0;
const double _kAvatarEffectiveHeight =
kAvatarSize - (kHeaderHeight - _kAvatarTopPadding);

View File

@@ -2,6 +2,7 @@ import 'package:PiliPlus/common/constants.dart';
import 'package:PiliPlus/common/widgets/avatars.dart';
import 'package:PiliPlus/common/widgets/image_viewer/hero.dart';
import 'package:PiliPlus/common/widgets/pendant_avatar.dart';
import 'package:PiliPlus/common/widgets/scroll_physics.dart';
import 'package:PiliPlus/common/widgets/view_safe_area.dart';
import 'package:PiliPlus/models/common/image_preview_type.dart';
import 'package:PiliPlus/models/common/member/user_info_type.dart';
@@ -10,6 +11,7 @@ import 'package:PiliPlus/models_new/space/space/followings_followed_upper.dart';
import 'package:PiliPlus/models_new/space/space/images.dart';
import 'package:PiliPlus/models_new/space/space/live.dart';
import 'package:PiliPlus/models_new/space/space/pr_info.dart';
import 'package:PiliPlus/models_new/space/space/top.dart';
import 'package:PiliPlus/pages/fan/view.dart';
import 'package:PiliPlus/pages/follow/view.dart';
import 'package:PiliPlus/pages/follow_type/followed/view.dart';
@@ -38,6 +40,7 @@ class UserInfoCard extends StatelessWidget {
required this.onFollow,
this.live,
this.silence,
required this.headerControllerBuilder,
});
final bool isOwner;
@@ -47,6 +50,7 @@ class UserInfoCard extends StatelessWidget {
final VoidCallback onFollow;
final Live? live;
final int? silence;
final ValueGetter<PageController> headerControllerBuilder;
@override
Widget build(BuildContext context) {
@@ -111,37 +115,6 @@ class UserInfoCard extends StatelessWidget {
);
}
Widget _buildHeader(
BuildContext context,
ColorScheme colorScheme,
bool isLight,
double width,
) {
String imgUrl =
(isLight
? images.imgUrl
: images.nightImgurl.isNullOrEmpty
? images.imgUrl
: images.nightImgurl)
.http2https;
return GestureDetector(
onTap: () => PageUtils.imageView(imgList: [SourceModel(url: imgUrl)]),
child: fromHero(
tag: imgUrl,
child: CachedNetworkImage(
fit: .cover,
height: kHeaderHeight,
width: width,
memCacheWidth: width.cacheSize(context),
imageUrl: ImageUtils.thumbnailUrl(imgUrl),
placeholder: (_, _) => const SizedBox.shrink(),
color: isLight ? const Color(0x5DFFFFFF) : const Color(0x8D000000),
colorBlendMode: isLight ? BlendMode.lighten : BlendMode.darken,
),
),
);
}
List<Widget> _buildLeft(
BuildContext context,
ColorScheme colorScheme,
@@ -455,15 +428,16 @@ class UserInfoCard extends StatelessWidget {
],
);
Widget get _buildAvatar => fromHero(
Widget _buildAvatar(bool hasPendant) => fromHero(
tag: card.face ?? '',
child: PendantAvatar(
avatar: card.face,
size: kAvatarSize,
size: hasPendant ? kPendantAvatarSize : kAvatarSize,
isMemberAvatar: true,
badgeSize: 20,
officialType: card.officialVerify?.type,
isVip: (card.vip?.status ?? -1) > 0,
garbPendantImage: card.pendant!.image!,
garbPendantImage: card.pendant?.image,
roomId: live?.liveStatus == 1 ? live!.roomid : null,
onTap: () => PageUtils.imageView(
imgList: [SourceModel(url: card.face.http2https)],
@@ -473,25 +447,146 @@ class UserInfoCard extends StatelessWidget {
Column _buildV(
BuildContext context,
ColorScheme colorScheme,
ColorScheme scheme,
bool isLight,
double width,
) => Column(
) {
final hasPendant = card.pendant?.image?.isNotEmpty ?? false;
final imgUrls = images.collectionTopSimple?.top?.imgUrls;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
HeaderLayoutWidget(
header: _buildHeader(context, colorScheme, isLight, width),
avatar: _buildAvatar,
actions: _buildRight(colorScheme),
header: imgUrls != null
? _buildCollectionHeader(context, scheme, isLight, imgUrls, width)
: _buildHeader(
context,
isLight,
width,
(isLight
? images.imgUrl
: images.nightImgurl.isNullOrEmpty
? images.imgUrl
: images.nightImgurl)
.http2https,
),
avatar: _buildAvatar(hasPendant),
actions: _buildRight(scheme),
),
const SizedBox(height: 5),
..._buildLeft(context, colorScheme, isLight),
..._buildLeft(context, scheme, isLight),
if (card.prInfo?.content?.isNotEmpty == true)
buildPrInfo(context, colorScheme, isLight, card.prInfo!),
buildPrInfo(context, scheme, isLight, card.prInfo!),
const SizedBox(height: 5),
],
);
}
Widget _buildCollectionHeader(
BuildContext context,
ColorScheme scheme,
bool isLight,
List<TopImage> imgUrls,
double width,
) {
if (imgUrls.length == 1) {
return _buildHeader(
context,
isLight,
width,
imgUrls.single.cover,
filter: false,
);
}
final controller = headerControllerBuilder();
final memCacheWidth = width.cacheSize(context);
return GestureDetector(
behavior: .opaque,
onTap: () => PageUtils.imageView(
initialPage: controller.page?.round() ?? 0,
imgList: imgUrls.map((e) => SourceModel(url: e.cover)).toList(),
onPageChanged: controller.jumpToPage,
),
child: Stack(
children: [
SizedBox(
width: .infinity,
height: kHeaderHeight,
child: PageView.builder(
controller: controller,
itemCount: imgUrls.length,
physics: clampingScrollPhysics,
itemBuilder: (context, index) {
final img = imgUrls[index];
return fromHero(
tag: img.cover,
child: CachedNetworkImage(
fit: .cover,
alignment: Alignment(0.0, img.dy),
height: kHeaderHeight,
width: width,
memCacheWidth: memCacheWidth,
imageUrl: ImageUtils.thumbnailUrl(img.cover),
fadeInDuration: const Duration(milliseconds: 120),
fadeOutDuration: const Duration(milliseconds: 120),
placeholder: (_, _) =>
const SizedBox(width: .infinity, height: kHeaderHeight),
),
);
},
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: HeaderIndicator(
length: imgUrls.length,
pageController: controller,
),
),
],
),
);
}
Widget _buildHeader(
BuildContext context,
bool isLight,
double width,
String imgUrl, {
bool filter = true,
}) {
return GestureDetector(
behavior: .opaque,
onTap: () => PageUtils.imageView(imgList: [SourceModel(url: imgUrl)]),
child: fromHero(
tag: imgUrl,
child: CachedNetworkImage(
fit: .cover,
height: kHeaderHeight,
width: width,
memCacheWidth: width.cacheSize(context),
imageUrl: ImageUtils.thumbnailUrl(imgUrl),
placeholder: (_, _) =>
const SizedBox(width: .infinity, height: kHeaderHeight),
color: filter
? isLight
? const Color(0x5DFFFFFF)
: const Color(0x8D000000)
: null,
colorBlendMode: filter
? isLight
? BlendMode.lighten
: BlendMode.darken
: null,
fadeInDuration: const Duration(milliseconds: 120),
fadeOutDuration: const Duration(milliseconds: 120),
),
),
);
}
Widget buildPrInfo(
BuildContext context,
@@ -520,6 +615,8 @@ class UserInfoCard extends StatelessWidget {
memCacheHeight: 20.cacheSize(context),
imageUrl: ImageUtils.thumbnailUrl(icon),
placeholder: (_, _) => const SizedBox.shrink(),
fadeInDuration: .zero,
fadeOutDuration: .zero,
),
const SizedBox(width: 16),
],
@@ -554,7 +651,7 @@ class UserInfoCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// _buildHeader(context),
const SizedBox(height: 56),
const SizedBox(height: kToolbarHeight),
Row(
children: [
const SizedBox(width: 20),
@@ -563,7 +660,7 @@ class UserInfoCard extends StatelessWidget {
top: 10,
bottom: card.prInfo?.content?.isNotEmpty == true ? 0 : 10,
),
child: _buildAvatar,
child: _buildAvatar(card.pendant?.image?.isNotEmpty ?? false),
),
const SizedBox(width: 10),
Expanded(
@@ -632,3 +729,54 @@ class UserInfoCard extends StatelessWidget {
);
}
}
class HeaderIndicator extends StatefulWidget {
const HeaderIndicator({
super.key,
required this.length,
required this.pageController,
});
final int length;
final PageController pageController;
@override
State<HeaderIndicator> createState() => _HeaderIndicatorState();
}
class _HeaderIndicatorState extends State<HeaderIndicator> {
late double _progress;
@override
void initState() {
super.initState();
_updateProgress();
widget.pageController.addListener(_listener);
}
void _listener() {
_updateProgress();
setState(() {});
}
void _updateProgress() {
_progress = ((widget.pageController.page ?? 0) + 1) / widget.length;
}
@override
void dispose() {
widget.pageController.removeListener(_listener);
super.dispose();
}
@override
Widget build(BuildContext context) {
return LinearProgressIndicator(
// ignore: deprecated_member_use
year2023: true,
minHeight: 3.5,
backgroundColor: const Color(0xA09E9E9E),
value: _progress,
);
}
}

View File

@@ -48,6 +48,7 @@ abstract final class PageUtils {
int initialPage = 0,
required List<SourceModel> imgList,
int? quality,
ValueChanged<int>? onPageChanged,
}) {
return Get.key.currentState!.push<void>(
HeroDialogRoute(
@@ -55,6 +56,7 @@ abstract final class PageUtils {
sources: imgList,
initIndex: initialPage,
quality: quality ?? GlobalData().imgQuality,
onPageChanged: onPageChanged,
),
),
);