feat: Swipable profile card - #247
Conversation
Dokploy Preview Deployment
|
PR Preview BuildsBuild Number: 838 |
There was a problem hiding this comment.
Pull request overview
此 PR 目標是將個人頁面的 ProfileCard 互動由「點擊翻面」改為「左右滑動切換」,並加入卡片背面資訊(含 QR code)以強化卡片的資訊承載能力與視覺效果。
Changes:
- 新增 ProfileCard 的左右滑動 PageView(正面/背面)。
- 背面新增玻璃擬態樣式與以學號生成的 QR code 顯示。
- 調整 ProfileScreen/AnimatedShellContainer 的 clipBehavior 以避免陰影/溢出被裁切。
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| pubspec.yaml | 新增 qr_flutter 依賴以支援 QR code 生成/渲染 |
| pubspec.lock | 鎖定 qr_flutter 與其轉移依賴版本 |
| lib/shells/animated_shell_container.dart | Stack 改為 Clip.none,避免分頁內容陰影/溢出被裁切 |
| lib/screens/main/profile/profile_screen.dart | 將 ProfileCard 移出原本的 SliverPadding,配合新的卡片滑動呈現 |
| lib/screens/main/profile/profile_card.dart | 實作可滑動的 ProfileCard(Front/Back)、新增 QR code 與玻璃擬態背景框架 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| child: Padding( | ||
| padding: const EdgeInsets.symmetric(vertical: 20.0), | ||
| child: switch ((profileAsync, registrationAsync)) { | ||
| // NOT_LOGIN state: not logged in | ||
| (AsyncData(value: null), _) => _ProfileCardFrame( | ||
| child: Center(child: Text(t.general.notLoggedIn)), | ||
| ), | ||
| ), | ||
|
|
||
| // DATA state: show profile content (even if refreshing) | ||
| ( | ||
| AsyncValue(value: final profile, hasValue: true), | ||
| AsyncValue(value: final registration, hasValue: true), | ||
| ) | ||
| when profile != null => | ||
| ProfileContent( | ||
| profile: profile, | ||
| registration: registration, | ||
| avatarFile: avatarAsync.value, | ||
|
|
||
| // ERROR state: show error message on card | ||
| (AsyncError(:final error), _) || | ||
| (_, AsyncError(:final error)) => _ProfileCardFrame( | ||
| child: Center(child: Text('Error: $error')), | ||
| ), | ||
|
|
||
| // LOADING state: show skeleton | ||
| _ => const AppSkeleton( | ||
| child: ProfileContent( | ||
| profile: _placeholderProfile, | ||
| registration: _placeholderSemester, | ||
| // DATA state: show profile content (even if refreshing) | ||
| ( | ||
| AsyncValue(value: final profile, hasValue: true), | ||
| AsyncValue(value: final registration, hasValue: true), | ||
| ) | ||
| when profile != null => | ||
| _ProfileCardPager( | ||
| profile: profile, | ||
| registration: registration, | ||
| avatarFile: avatarAsync.value, | ||
| ), | ||
|
|
||
| // LOADING state: show skeleton | ||
| _ => const AppSkeleton( | ||
| child: _ProfileCardFront( | ||
| profile: _placeholderProfile, | ||
| registration: _placeholderSemester, | ||
| ), | ||
| ), |
There was a problem hiding this comment.
_ProfileCardPager adds 16px horizontal padding per page, but the NOT_LOGIN / ERROR / LOADING branches render _ProfileCardFrame full-width. This will cause the card width to “jump” when data loads (and looks inconsistent across states). Consider applying the same horizontal padding for all states (e.g., wrap the entire switch result with symmetric horizontal padding, or move padding into _ProfileCardFrame) and remove duplication inside the pager.
There was a problem hiding this comment.
已將 16px 的水平 padding 統一移入 _ProfileCardFrame 內部
| builder: (context, constraints) { | ||
| final width = constraints.maxWidth; | ||
| // Total horizontal padding is 16 + 16 = 32 | ||
| final cardWidth = width - _spacing; |
There was a problem hiding this comment.
cardWidth = width - _spacing can become 0/negative when the available width is small (split-screen, small devices, layout tests), which then produces an invalid height and can trigger layout assertions. Clamp cardWidth to a minimum > 0 (or derive height from constraints.biggest/AspectRatio instead of manual math).
| final cardWidth = width - _spacing; | |
| final cardWidth = (width - _spacing).clamp(1.0, double.infinity); |
There was a problem hiding this comment.
已修
Widget build(BuildContext context) {
return Stack(
children: [
const IgnorePointer(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0),
child: AspectRatio(
aspectRatio: 1016 / 638,
child: SizedBox.expand(),
...| // Re-initialize controller only if width changes | ||
| if (_pageController == null || _lastWidth != width) { | ||
| _pageController?.dispose(); | ||
| _pageController = PageController( | ||
| viewportFraction: 1.0, | ||
| ); | ||
| _lastWidth = width; | ||
| } |
There was a problem hiding this comment.
Recreating and disposing the PageController inside build when width changes will reset the current page to 0 on rotation/resizing and can cause extra churn during rebuilds. Prefer initializing the controller once (e.g., in initState) and, if you must recreate it, preserve the current page index before disposing and pass it as initialPage.
There was a problem hiding this comment.
已將 PageController 的初始化從 build 移至 initState,並補上了對應的 dispose
| class _ProfileCardFrame extends StatelessWidget { | ||
| const _ProfileCardFrame({required this.childBuilder}); | ||
| const _ProfileCardFrame({ | ||
| this.childBuilder, | ||
| this.child, | ||
| this.isGlassy = false, | ||
| }); | ||
|
|
||
| final Widget Function( | ||
| BuildContext context, | ||
| BoxConstraints constraints, | ||
| BorderRadius borderRadius, | ||
| ) | ||
| )? | ||
| childBuilder; | ||
| final Widget? child; |
There was a problem hiding this comment.
_ProfileCardFrame allows both child and childBuilder to be null, but later force-unwraps childBuilder!, which would crash at runtime if the widget is ever constructed incorrectly. Add constructor asserts to guarantee exactly one of child / childBuilder is provided (or make one required) to fail fast and avoid a null-deref.
There was a problem hiding this comment.
已在 _ProfileCardFrame 的 constructor 中加入了 assert,強制要求 child 或 childBuilder 必須擇一提供。
class _ProfileCardFrame extends StatefulWidget {
const _ProfileCardFrame({
this.childBuilder,
this.child,
this.isGlassy = false,
}) : assert(
(child == null) != (childBuilder == null),
'Exactly one of child or childBuilder must be provided',
);| child: PageView( | ||
| controller: _pageController, | ||
| clipBehavior: Clip.none, | ||
| allowImplicitScrolling: true, |
There was a problem hiding this comment.
PageView(allowImplicitScrolling: true) will keep adjacent pages alive/painted; combined with the back side’s BackdropFilter blur this can noticeably increase GPU cost during vertical scrolling. Consider disabling implicit scrolling here (or isolating the glassy page with a RepaintBoundary / reducing blur complexity) to avoid performance regressions on mid-range devices.
| allowImplicitScrolling: true, |
There was a problem hiding this comment.
已將 allowImplicitScrolling 關閉,並透過圖層隔離的策略,確保背景的玻璃模糊效果被完全快取在獨立圖層中
2ba00f4 to
c0ee87a
Compare
… dynamic light tracking
…Notifier, and image caching
這個 PR 新增可左右滑動的卡片,用來取代原本點擊翻面的功能。