From 53497edc62ba40d857e5fa0dfb8426dd85b54609 Mon Sep 17 00:00:00 2001 From: ivan Date: Wed, 22 Apr 2026 23:04:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E4=B8=89=E6=96=B9?= =?UTF-8?q?=E6=94=AF=E4=BB=98=E4=BC=98=E5=8C=96=EF=BC=8C=E7=A7=AF=E5=88=86?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/pencil-mcp.mdc | 11 + assets/images/checker_20px_500.png | Bin 0 -> 2587 bytes desgin/funymee_home.pen | 597 ++++++++++++++++-- lib/design/pencil_theme.dart | 73 ++- lib/features/generate/generate_screen.dart | 180 ++++-- lib/features/history/credit_record_tab.dart | 156 ++++- lib/features/history/history_screen.dart | 240 ++++--- .../history/history_task_progress_screen.dart | 6 +- .../history/widgets/history_grid_card.dart | 106 +++- lib/features/profile/delete_account_flow.dart | 129 ++++ lib/features/profile/profile_screen.dart | 6 +- lib/features/purchase/purchase_screen.dart | 174 +---- .../purchase/third_party_payment_sheet.dart | 551 ++++++++++++++++ .../report/report_feedback_upload.dart | 1 + lib/features/report/report_screen.dart | 175 ++++- .../image_upload_feedback_snackbars.dart | 103 +++ lib/widgets/pencil_chrome.dart | 140 ++-- .../pencil_yellow_white_background.dart | 40 ++ pubspec.yaml | 2 +- 19 files changed, 2193 insertions(+), 497 deletions(-) create mode 100644 .cursor/rules/pencil-mcp.mdc create mode 100644 assets/images/checker_20px_500.png create mode 100644 lib/features/purchase/third_party_payment_sheet.dart create mode 100644 lib/widgets/image_upload_feedback_snackbars.dart create mode 100644 lib/widgets/pencil_yellow_white_background.dart diff --git a/.cursor/rules/pencil-mcp.mdc b/.cursor/rules/pencil-mcp.mdc new file mode 100644 index 0000000..c057242 --- /dev/null +++ b/.cursor/rules/pencil-mcp.mdc @@ -0,0 +1,11 @@ +--- +description: Pencil (.pen) — use Cursor extension-pencil MCP; do not claim MCP is missing +globs: "**/*.pen" +alwaysApply: false +--- + +# Pencil MCP (extension-pencil) + +- **This repo’s Pencil files** live under `desgin/` (e.g. `funymee_home.pen`). Editing them should go through **Cursor’s Pencil MCP** when the session exposes those tools — in the UI this often appears as **extension-pencil** with **13 tools enabled** (green status). That connection is **configured in Cursor**, not necessarily in the workspace `mcp.json`. +- **Do not** tell the user Pencil MCP is “not configured” or “only dart exists” **based only on** reading `mcp.json`. **Do not** ask them to “connect MCP” or send screenshots to “enable” it unless a tool call actually fails with a clear error. +- When Pencil MCP tools are available in this session, **prefer them** for node/style updates in `.pen` files. If tools are not in the tool list, fall back to editing the `.pen` JSON directly and say that briefly once — without blaming the user’s setup. diff --git a/assets/images/checker_20px_500.png b/assets/images/checker_20px_500.png new file mode 100644 index 0000000000000000000000000000000000000000..6fd998d488f292736a7610243c0290859928ac4b GIT binary patch literal 2587 zcmeAS@N?(olHy`uVBq!ia0y~yVEh8Y9Be?5)7S2IF)(mG@^o&il{@ncE4{LWmto?WG-=Brd4h;*agAcrUXDw?{Y<2$o&H107 z*Hy&lRm{Kr`*S-V17j1FaItMc`TeJRW?IgD{`<}OpT+ij^v(C2zkh4KJ(U6q6xcKF z)-GpG_j~@+?sxIOJN)lY^S&>xuK#y|QNe*qIAI<8yLY?im>VzuTr>Z#W&OMM?@!yl z|BR+?U~S(0zT^RFjOV>SFK@-b!a*f`=IepGwbMVRJu8{_ezX1a^M60=yYs=WKAXCM zRrY-@-yGB!fBW~RGkXJrBXw}YPPrYs-xu9GGw0mr+HcSQ?3BMZUHabh{I}E%tatk^ zH=@S)&b{{aFBk+AsDur!GnaplJvSS*F!{5Vx`B1`Zn;$fYK(9H{+wTyfr*7m_*vD1 zcl%CP8lx5^`?pdzuy+6UV?T!)(zBxm#|TpN3MDeBSwnlgeZM|NHXcXCJ1Te>E=yHu@MmUHx3vIVCg!0JM`LMgRZ+ literal 0 HcmV?d00001 diff --git a/desgin/funymee_home.pen b/desgin/funymee_home.pen index 4b3a5f1..47890f7 100644 --- a/desgin/funymee_home.pen +++ b/desgin/funymee_home.pen @@ -1,5 +1,5 @@ { - "version": "2.10", + "version": "2.11", "children": [ { "type": "frame", @@ -184,47 +184,25 @@ "type": "frame", "id": "aHMps", "name": "createBtn", - "opacity": 0.88, + "opacity": 1, "width": 186, "height": 42, - "fill": { - "type": "gradient", - "gradientType": "linear", - "enabled": true, - "rotation": 90, - "size": { - "height": 1 - }, - "colors": [ - { - "color": "#FFFDE7", - "position": 0 - }, - { - "color": "#FDE047", - "position": 0.42 - }, - { - "color": "#F59E0B", - "position": 1 - } - ] - }, + "fill": "#FFFFFFA8", "cornerRadius": 999, "stroke": { "align": "inside", - "thickness": 2, - "fill": "#FFFFFFD9" + "thickness": 1.5, + "fill": "#FFFFFFCC" }, "effect": { "type": "shadow", "shadowType": "outer", - "color": "#B4530952", + "color": "#00000026", "offset": { "x": 0, - "y": 10 + "y": 6 }, - "blur": 28 + "blur": 20 }, "gap": 10, "padding": [ @@ -240,23 +218,8 @@ "name": "plusCirc", "width": 24, "height": 24, - "fill": "#FFFFFF", + "fill": "#FDE047", "cornerRadius": 20, - "stroke": { - "align": "inside", - "thickness": 1.5, - "fill": "#F59E0B99" - }, - "effect": { - "type": "shadow", - "shadowType": "outer", - "color": "#00000014", - "offset": { - "x": 0, - "y": 2 - }, - "blur": 6 - }, "justifyContent": "center", "alignItems": "center", "children": [ @@ -268,7 +231,7 @@ "height": 12, "iconFontName": "plus", "iconFontFamily": "lucide", - "fill": "#B45309" + "fill": "#000000" } ] }, @@ -6093,6 +6056,546 @@ ] } ] + }, + { + "type": "frame", + "id": "p7kQm", + "x": 6500, + "y": 0, + "name": "第三方支付 · 底部表单", + "clip": true, + "width": 390, + "height": 844, + "fill": "#00000080", + "layout": "vertical", + "justifyContent": "end", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "zL8hY", + "name": "sheetPayment", + "clip": true, + "width": "fill_container", + "fill": [ + { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 180, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#FDE047", + "position": 0 + }, + { + "color": "#FEF9C3", + "position": 0.22 + }, + { + "color": "#FFFBEB", + "position": 0.65 + }, + { + "color": "#FFFBF0", + "position": 1 + } + ] + }, + { + "type": "image", + "opacity": 0.2, + "enabled": true, + "url": "../assets/images/checker_20px_500.png", + "mode": "fill" + } + ], + "cornerRadius": [ + 24, + 24, + 0, + 0 + ], + "stroke": { + "align": "inside", + "thickness": { + "top": 1, + "right": 1, + "left": 1 + }, + "fill": "#FDE68A" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#B4530918", + "offset": { + "x": 0, + "y": -4 + }, + "blur": 20 + }, + "layout": "vertical", + "gap": 18, + "padding": [ + 10, + 20, + 34, + 20 + ], + "children": [ + { + "type": "frame", + "id": "gR2mK", + "name": "handleRow", + "width": "fill_container", + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "wH9pN", + "name": "handlePill", + "width": 40, + "height": 5, + "fill": "#D6D3D1", + "cornerRadius": 99 + } + ] + }, + { + "type": "text", + "id": "bKx4T", + "name": "titlePayment", + "fill": "#1C1917", + "content": "Payment", + "textAlign": "center", + "fontFamily": "Inter", + "fontSize": 20, + "fontWeight": "700" + }, + { + "type": "frame", + "id": "cVn8Q", + "name": "summaryCard", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 16, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E7E5E4" + }, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#0000000D", + "offset": { + "x": 0, + "y": 4 + }, + "blur": 16 + }, + "padding": 16, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "mJ3fD", + "name": "summaryLeft", + "gap": 10, + "alignItems": "center", + "children": [ + { + "type": "icon_font", + "id": "yP2sL", + "name": "gemSum", + "width": 22, + "height": 22, + "iconFontName": "gem", + "iconFontFamily": "lucide", + "fill": "#EAB308" + }, + { + "type": "frame", + "id": "qW5nR", + "name": "creditTxtRow", + "gap": 4, + "children": [ + { + "type": "text", + "id": "aT7mK", + "name": "baseCredits", + "fill": "#1C1917", + "content": "500", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "text", + "id": "fN9bX", + "name": "bonusCredits", + "fill": "#16A34A", + "content": "+150", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + }, + { + "type": "text", + "id": "hL2cV", + "name": "creditsWord", + "fill": "#78716C", + "content": "Credits", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "text", + "id": "dM4wZ", + "name": "priceUsd", + "fill": "#1C1917", + "content": "$49.99", + "fontFamily": "Inter", + "fontSize": 24, + "fontWeight": "800" + } + ] + }, + { + "type": "text", + "id": "sB6nY", + "name": "sectionPayment", + "fill": "#57534E", + "content": "Payment", + "fontFamily": "Inter", + "fontSize": 14, + "fontWeight": "600" + }, + { + "type": "frame", + "id": "kR8tP", + "name": "methodList", + "width": "fill_container", + "layout": "vertical", + "gap": 12, + "children": [ + { + "type": "frame", + "id": "optApple", + "name": "rowApplePay", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#3B82F6" + }, + "padding": 14, + "justifyContent": "space_between", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "rL1aQ", + "name": "rowAppleLeft", + "gap": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "radOn", + "name": "radioOn", + "width": 22, + "height": 22, + "fill": "#3B82F6", + "cornerRadius": 99, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#3B82F6" + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "radDot", + "name": "radioInner", + "width": 8, + "height": 8, + "fill": "#FFFFFF", + "cornerRadius": 99 + } + ] + }, + { + "type": "icon_font", + "id": "icApple", + "name": "icWallet", + "width": 28, + "height": 28, + "iconFontName": "wallet", + "iconFontFamily": "lucide", + "fill": "#1C1917" + }, + { + "type": "frame", + "id": "colApple", + "name": "colAppleTxt", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "tApple1", + "name": "tAppleTitle", + "fill": "#1C1917", + "content": "Apple Pay", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "tApple2", + "name": "tAppleSub", + "fill": "#B45309", + "content": "+ 30% More Credits", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "tagRec", + "name": "tagRecommend", + "fill": "#F5F5F4", + "cornerRadius": 8, + "padding": [ + 5, + 10 + ], + "children": [ + { + "type": "text", + "id": "tRec", + "name": "tRecommend", + "fill": "#44403C", + "content": "Recommend", + "fontFamily": "Inter", + "fontSize": 11, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "optCard", + "name": "rowCard", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E7E5E4" + }, + "gap": 12, + "padding": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "radOff1", + "name": "radioOff1", + "width": 22, + "height": 22, + "fill": "#FFFFFF", + "cornerRadius": 99, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#D6D3D1" + } + }, + { + "type": "icon_font", + "id": "icCards", + "name": "icCreditCards", + "width": 28, + "height": 28, + "iconFontName": "credit-card", + "iconFontFamily": "lucide", + "fill": "#1C1917" + }, + { + "type": "frame", + "id": "colCard", + "name": "colCardTxt", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "tCard1", + "name": "tCardTitle", + "fill": "#1C1917", + "content": "Credit Card", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + }, + { + "type": "text", + "id": "tCard2", + "name": "tCardSub", + "fill": "#B45309", + "content": "+ 20% More Credits", + "fontFamily": "Inter", + "fontSize": 12, + "fontWeight": "600" + } + ] + } + ] + }, + { + "type": "frame", + "id": "optStore", + "name": "rowAppStore", + "width": "fill_container", + "fill": "#FFFFFF", + "cornerRadius": 14, + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "#E7E5E4" + }, + "gap": 12, + "padding": 14, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "radOff2", + "name": "radioOff2", + "width": 22, + "height": 22, + "fill": "#FFFFFF", + "cornerRadius": 99, + "stroke": { + "align": "inside", + "thickness": 2, + "fill": "#D6D3D1" + } + }, + { + "type": "icon_font", + "id": "icBag", + "name": "icStore", + "width": 28, + "height": 28, + "iconFontName": "shopping-bag", + "iconFontFamily": "lucide", + "fill": "#007AFF" + }, + { + "type": "frame", + "id": "colStore", + "name": "colStoreTxt", + "width": "fill_container", + "layout": "vertical", + "gap": 4, + "children": [ + { + "type": "text", + "id": "tStore1", + "name": "tStoreTitle", + "fill": "#1C1917", + "content": "App Store", + "fontFamily": "Inter", + "fontSize": 16, + "fontWeight": "700" + } + ] + } + ] + } + ] + }, + { + "type": "frame", + "id": "btnPayPrimary", + "name": "payCta", + "width": "fill_container", + "height": 52, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 180, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#EAB308", + "position": 0 + }, + { + "color": "#CA8A04", + "position": 1 + } + ] + }, + "cornerRadius": 999, + "effect": { + "type": "shadow", + "shadowType": "outer", + "color": "#B4530940", + "offset": { + "x": 0, + "y": 6 + }, + "blur": 14 + }, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "tPayBtn", + "name": "tPayCta", + "fill": "#FFFFFF", + "content": "Pay $49.99", + "fontFamily": "Inter", + "fontSize": 17, + "fontWeight": "700" + } + ] + } + ] + } + ] } ] } \ No newline at end of file diff --git a/lib/design/pencil_theme.dart b/lib/design/pencil_theme.dart index c2289c7..b93619a 100644 --- a/lib/design/pencil_theme.dart +++ b/lib/design/pencil_theme.dart @@ -23,7 +23,7 @@ abstract final class PencilTheme { ]; static const Color gemYellow = Color(0xFFFFD60A); - /// 旧版 Create Now 磨砂底(当前 UI 已改用金渐变 [PencilCreateNowButton];保留供参考)。 + /// 旧版 Create Now 磨砂底(当前 [PencilCreateNowButton] 与 pen `aHMps` 白半透明一致;保留供参考)。 static const Color createPillFill = Color(0x4DFFFFFF); static const Color createPlusDisc = Color(0xFFFFD60A); @@ -65,6 +65,77 @@ abstract final class PencilTheme { static const Color genSlotBorder = Color(0xFFF5D08A); static const Color genNavBackStroke = Color(0xFFE7E5E4); + /// 支付方式行选中描边(`funymee_home.pen` optApple / zL8hY) + static const Color paymentMethodSelectedStroke = Color(0xFF3B82F6); + + /// 单选未选中描边 + static const Color paymentRadioOffStroke = Color(0xFFD6D3D1); + + /// `zL8hY` 底部表单主体渐变(pen rotation 180,上→下) + static const LinearGradient paymentSheetBodyGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFFFDE047), + Color(0xFFFEF9C3), + Color(0xFFFFFBEB), + Color(0xFFFFFBF0), + ], + stops: [0, 0.22, 0.65, 1], + ); + + /// 第三方支付弹窗顶部遮罩:纯黑半透明(modal scrim,与 `p7kQm` 一致) + static const Color paymentModalDimScrim = Color(0x80000000); + + /// `zL8hY` 外阴影 `#B4530918`,offset (0,-4),blur 20 + static const List paymentSheetOuterShadow = [ + BoxShadow( + color: Color(0x18B45309), + offset: Offset(0, -4), + blurRadius: 20, + ), + ]; + + /// 摘要卡钻石图标 `gemSum` #EAB308 + static const Color paymentSummaryGem = Color(0xFFEAB308); + + /// 摘要卡赠送积分 `#16A34A` + static const Color paymentSummaryBonusGreen = Color(0xFF16A34A); + + /// 摘要卡 “Credits” 标签 `#78716C` + static const Color paymentSummaryCreditsLabel = Color(0xFF78716C); + + /// 摘要卡投影 `#0000000D`,offset (0,4),blur 16 + static const List paymentSummaryCardShadow = [ + BoxShadow( + color: Color(0x0D000000), + offset: Offset(0, 4), + blurRadius: 16, + ), + ]; + + /// `payCta` 金渐变 `#EAB308` → `#CA8A04` + static const LinearGradient paymentPayButtonGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFFEAB308), + Color(0xFFCA8A04), + ], + ); + + /// `payCta` 投影 `#B4530940`,offset (0,6),blur 14 + static const List paymentPayButtonShadow = [ + BoxShadow( + color: Color(0x40B45309), + offset: Offset(0, 6), + blurRadius: 14, + ), + ]; + + /// App Store 行图标色 `icBag` #007AFF + static const Color paymentAppStoreIconBlue = Color(0xFF007AFF); + /// Credit Record 流水行卡片(`funymee_home.pen` listCr / ez9wP) static const LinearGradient creditRecordRowGradient = LinearGradient( begin: Alignment.centerLeft, diff --git a/lib/features/generate/generate_screen.dart b/lib/features/generate/generate_screen.dart index 26a5b3c..d3fc45c 100644 --- a/lib/features/generate/generate_screen.dart +++ b/lib/features/generate/generate_screen.dart @@ -13,7 +13,9 @@ import '../../core/auth/auth_service.dart'; import '../../core/open_purchase_store.dart'; import '../../core/user/user_state.dart'; import '../../design/pencil_theme.dart'; +import '../../widgets/image_upload_feedback_snackbars.dart'; import '../../widgets/pencil_chrome.dart'; +import '../../widgets/pencil_yellow_white_background.dart'; import '../../widgets/resilient_network_video.dart'; import 'generate_result_screen.dart'; @@ -35,6 +37,9 @@ class _GenerateScreenState extends State { File? _picked; File? _picked2; + /// 正在从相册/相机加载并校验的槽位(`0` / `1`);`null` 表示未在选图。 + int? _pickLoadingSlot; + /// 对应 create-task 逻辑字段 `size`(换皮 `seminar`),如 480p / 720p。 String _outputSize = '720p'; @@ -49,6 +54,9 @@ class _GenerateScreenState extends State { bool _pollNavigated = false; String? _pollTaskId; + /// 来自本地缓存的 [UploadPresignedUrlResponse.expectedSize](预签名成功后写入);无缓存时为 [ImageUploadExpectedSizeCache.fallbackMaxBytes]。 + int _maxUploadBytes = ImageUploadExpectedSizeCache.fallbackMaxBytes; + static const double _slotW = 112; static const double _slotH = 108; static const double _previewH = 359; @@ -127,6 +135,51 @@ class _GenerateScreenState extends State { void initState() { super.initState(); _syncOutputSizeForTemplate(); + unawaited(_refreshUploadLimitFromCache()); + } + + Future _refreshUploadLimitFromCache() async { + final v = await ImageUploadExpectedSizeCache.readImageMaxBytesForUi(); + if (!mounted) return; + setState(() => _maxUploadBytes = v); + if (!mounted) return; + await _prunePickedIfOverLimit(); + } + + Future _prunePickedIfOverLimit() async { + final maxB = _maxUploadBytes; + if (maxB <= 0) return; + var cleared = false; + if (_picked != null && await _picked!.length() > maxB) { + _picked = null; + cleared = true; + } + if (_picked2 != null && await _picked2!.length() > maxB) { + _picked2 = null; + cleared = true; + } + if (cleared && mounted) { + setState(() {}); + showImageClearedOverLimitSnackBar(context); + } + } + + String _uploadTipsBody() { + final cap = _formatMaxUploadLabel(_maxUploadBytes); + return 'Upload JPG or PNG ($cap each). You can use the camera or photo library. Use clear, front-facing photos when possible.'; + } + + String _formatMaxUploadLabel(int bytes) { + if (bytes <= 0) return 'max —'; + if (bytes >= 1024 * 1024) { + final mb = bytes / (1024 * 1024); + final s = mb >= 10 ? mb.toStringAsFixed(0) : mb.toStringAsFixed(1); + return 'max $s MB'; + } + if (bytes >= 1024) { + return 'max ${(bytes / 1024).round()} KB'; + } + return 'max $bytes bytes'; } @override @@ -139,21 +192,38 @@ class _GenerateScreenState extends State { } Future _pickSlot(int slot) async { - if (_generating) return; + if (_generating || _pickLoadingSlot != null) return; if (!mounted) return; final source = await _showPickImageSourceSheet(context); if (source == null || !mounted) return; - final x = await AuthService.runWithNativeMediaPicker( - () => _picker.pickImage(source: source, imageQuality: 92), - ); - if (x == null || !mounted) return; - setState(() { - if (slot == 0) { - _picked = File(x.path); - } else { - _picked2 = File(x.path); + setState(() => _pickLoadingSlot = slot); + try { + final x = await AuthService.runWithNativeMediaPicker( + () => _picker.pickImage(source: source, imageQuality: 92), + ); + if (x == null || !mounted) return; + final f = File(x.path); + final len = await f.length(); + final maxB = await ImageUploadExpectedSizeCache.readImageMaxBytesForUi(); + if (!mounted) return; + if (len > maxB) { + if (!mounted) return; + showImageExceedsMaxUploadSnackBar(context); + return; } - }); + setState(() { + if (maxB != _maxUploadBytes) _maxUploadBytes = maxB; + if (slot == 0) { + _picked = f; + } else { + _picked2 = f; + } + }); + } finally { + if (mounted) { + setState(() => _pickLoadingSlot = null); + } + } } Future _showPickImageSourceSheet(BuildContext context) { @@ -459,11 +529,7 @@ class _GenerateScreenState extends State { @override Widget build(BuildContext context) { - final credits = UserState.credits.value; - return Container( - decoration: const BoxDecoration( - gradient: PencilTheme.yellowWhitePageGradient, - ), + return PencilYellowWhitePageBackground( child: Scaffold( backgroundColor: Colors.transparent, body: SafeArea( @@ -844,32 +910,68 @@ class _GenerateScreenState extends State { side: const BorderSide(color: PencilTheme.genSlotBorder, width: 1.5), ), child: InkWell( - onTap: () => _pickSlot(slotIndex), + onTap: (_generating || _pickLoadingSlot != null) + ? null + : () => _pickSlot(slotIndex), borderRadius: BorderRadius.circular(16), - child: file == null - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.add_photo_alternate_outlined, - color: PencilTheme.profileAvatarIcon, - size: 26, + child: Stack( + fit: StackFit.expand, + children: [ + file == null + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_photo_alternate_outlined, + color: PencilTheme.profileAvatarIcon, + size: 26, + ), + const SizedBox(height: 6), + Text( + label, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: PencilTheme.stone600, + ), + ), + ], + ) + : ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Image.file(file, fit: BoxFit.cover), ), - const SizedBox(height: 6), - Text( - label, - style: GoogleFonts.inter( - fontSize: 11, - fontWeight: FontWeight.w700, - color: PencilTheme.stone600, - ), + if (_pickLoadingSlot == slotIndex) + ColoredBox( + color: Colors.white.withValues(alpha: 0.88), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 26, + height: 26, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: PencilTheme.profileAvatarIcon, + ), + ), + const SizedBox(height: 8), + Text( + 'Loading…', + style: GoogleFonts.inter( + fontSize: 10, + fontWeight: FontWeight.w800, + color: PencilTheme.stone700, + letterSpacing: 0.2, + ), + ), + ], ), - ], - ) - : ClipRRect( - borderRadius: BorderRadius.circular(14), - child: Image.file(file, fit: BoxFit.cover), + ), ), + ], + ), ), ), ); @@ -913,7 +1015,7 @@ class _GenerateScreenState extends State { ), const SizedBox(height: 8), Text( - 'Upload JPG or PNG (≤ 5 MB each). You can use the camera or photo library. Use clear, front-facing photos when possible.', + _uploadTipsBody(), style: GoogleFonts.inter( fontSize: 13, fontWeight: FontWeight.w500, diff --git a/lib/features/history/credit_record_tab.dart b/lib/features/history/credit_record_tab.dart index d168025..118a398 100644 --- a/lib/features/history/credit_record_tab.dart +++ b/lib/features/history/credit_record_tab.dart @@ -18,16 +18,25 @@ class CreditRecordTab extends StatefulWidget { } class _CreditRecordTabState extends State { + static const int _pageSize = 30; + bool _loading = true; + bool _loadingMore = false; + bool _hasMore = true; String? _error; List _records = []; + int _lastLoadedPage = 0; + /// 下拉刷新时递增,用于丢弃刷新前未完成的「加载更多」响应。 + int _listGeneration = 0; + final ScrollController _scrollController = ScrollController(); VoidCallback? _cancelLoginWait; @override void initState() { super.initState(); + _scrollController.addListener(_onScrollNearEnd); _cancelLoginWait = AuthService.whenLoginSucceeded( - onReady: _load, + onReady: _reload, onFailed: () { if (!mounted) return; setState(() { @@ -40,17 +49,52 @@ class _CreditRecordTabState extends State { @override void dispose() { + _scrollController.removeListener(_onScrollNearEnd); + _scrollController.dispose(); _cancelLoginWait?.call(); super.dispose(); } - Future _load() async { + void _onScrollNearEnd() { + if (!_scrollController.hasClients) return; + if (_loading || _loadingMore || !_hasMore || _error != null) return; + final pos = _scrollController.position; + if (!pos.hasViewportDimension) return; + final threshold = 240.0; + if (pos.pixels >= pos.maxScrollExtent - threshold) { + _loadMore(); + } + } + + bool _deriveHasMore( + CreditsPageInfoResponse data, { + required int newBatchLength, + required int totalAfterAppend, + }) { + final pages = data.pages; + final current = data.current ?? _lastLoadedPage; + if (pages != null && pages > 0) { + return current < pages; + } + final total = data.total; + if (total != null) { + return totalAfterAppend < total; + } + return newBatchLength >= _pageSize; + } + + Future _reload() async { + final gen = ++_listGeneration; setState(() { _loading = true; + _loadingMore = false; _error = null; + _hasMore = true; + _records = []; + _lastLoadedPage = 0; }); - final res = await UserApi.getCreditsPage(page: '1', size: '30', type: '1'); - if (!mounted) return; + final res = await UserApi.getCreditsPage(page: '1', size: '$_pageSize'); + if (!mounted || gen != _listGeneration) return; if (!res.isSuccess || res.data == null) { setState(() { _loading = false; @@ -58,9 +102,40 @@ class _CreditRecordTabState extends State { }); return; } + final incoming = res.data!.records ?? []; setState(() { _loading = false; - _records = res.data!.records ?? []; + _records = incoming; + _lastLoadedPage = 1; + _hasMore = _deriveHasMore( + res.data!, + newBatchLength: incoming.length, + totalAfterAppend: _records.length, + ); + }); + } + + Future _loadMore() async { + if (!_hasMore || _loadingMore || _loading || _error != null) return; + final gen = _listGeneration; + final nextPage = _lastLoadedPage + 1; + setState(() => _loadingMore = true); + final res = await UserApi.getCreditsPage(page: '$nextPage', size: '$_pageSize'); + if (!mounted || gen != _listGeneration) return; + if (!res.isSuccess || res.data == null) { + setState(() => _loadingMore = false); + return; + } + final incoming = res.data!.records ?? []; + setState(() { + _loadingMore = false; + _records.addAll(incoming); + _lastLoadedPage = nextPage; + _hasMore = _deriveHasMore( + res.data!, + newBatchLength: incoming.length, + totalAfterAppend: _records.length, + ); }); } @@ -72,6 +147,25 @@ class _CreditRecordTabState extends State { return DateFormat('yyyy/MM/dd').format(dt); } + /// 接口约定:`type == 1` 增加,`type == 2` 扣除(展示为 `-xx`);其余按 [credits] 正负回退。 + (String label, Color amountColor) _amountStyle(CreditRecordItem r) { + const increaseColor = Colors.white; + const deductionColor = Color(0xFFFF6B6B); + final raw = r.credits; + if (raw == null) return ('—', increaseColor); + final n = raw.abs(); + switch (r.type) { + case 1: + return ('+$n', increaseColor); + case 2: + return ('-$n', deductionColor); + default: + if (raw > 0) return ('+$raw', increaseColor); + if (raw < 0) return ('$raw', deductionColor); + return ('0', increaseColor); + } + } + @override Widget build(BuildContext context) { final bottomPad = EdgeInsets.only(bottom: widget.extraBottomInset); @@ -91,7 +185,7 @@ class _CreditRecordTabState extends State { mainAxisSize: MainAxisSize.min, children: [ Text(_error!), - TextButton(onPressed: _load, child: const Text('Retry')), + TextButton(onPressed: _reload, child: const Text('Retry')), ], ), ), @@ -100,31 +194,61 @@ class _CreditRecordTabState extends State { if (_records.isEmpty) { return Padding( padding: bottomPad, - child: Center( - child: Text( - 'No records.', - style: GoogleFonts.inter(color: PencilTheme.inkSoft), + child: RefreshIndicator( + color: PencilTheme.underlineGold, + onRefresh: _reload, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: 320, + child: Center( + child: Text( + 'No records.', + style: GoogleFonts.inter(color: PencilTheme.inkSoft), + ), + ), + ), + ], ), ), ); } return RefreshIndicator( color: PencilTheme.underlineGold, - onRefresh: _load, + onRefresh: _reload, child: ListView.separated( + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), padding: EdgeInsets.fromLTRB( 16, 8, 16, 28 + widget.extraBottomInset, ), - itemCount: _records.length, + itemCount: _records.length + (_loadingMore ? 1 : 0), separatorBuilder: (_, _) => const SizedBox(height: 12), itemBuilder: (_, i) { + if (i >= _records.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Center( + child: SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: PencilTheme.underlineGold, + ), + ), + ), + ); + } final r = _records[i]; - final c = r.credits ?? 0; + final (amountLabel, amountColor) = _amountStyle(r); return _CreditRecordRowCard( - amountLabel: '${c > 0 ? '+' : ''}$c', + amountLabel: amountLabel, + amountColor: amountColor, dateLabel: _formatDate(r.createTime), ); }, @@ -137,10 +261,12 @@ class _CreditRecordTabState extends State { class _CreditRecordRowCard extends StatelessWidget { const _CreditRecordRowCard({ required this.amountLabel, + required this.amountColor, required this.dateLabel, }); final String amountLabel; + final Color amountColor; final String dateLabel; @override @@ -185,7 +311,7 @@ class _CreditRecordRowCard extends StatelessWidget { style: GoogleFonts.inter( fontSize: 18, fontWeight: FontWeight.w700, - color: Colors.white, + color: amountColor, ), ), ], diff --git a/lib/features/history/history_screen.dart b/lib/features/history/history_screen.dart index 9246785..141cd42 100644 --- a/lib/features/history/history_screen.dart +++ b/lib/features/history/history_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:client_proxy_framework/client_proxy_framework.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -7,7 +9,9 @@ import '../../core/auth/auth_service.dart'; import '../../core/user/user_state.dart'; import '../../design/pencil_theme.dart'; import '../../widgets/pencil_chrome.dart'; +import '../../widgets/pencil_yellow_white_background.dart'; import '../generate/generate_result_screen.dart'; +import '../profile/delete_account_flow.dart'; import 'credit_record_tab.dart'; import 'history_media_save.dart'; import 'history_task_progress_screen.dart'; @@ -39,6 +43,7 @@ class _HistoryScreenState extends State { List _items = []; Map _localCovers = {}; final Set _downloadingTaskIds = {}; + final Set _deletingTaskIds = {}; VoidCallback? _cancelLoginWait; @override @@ -79,6 +84,69 @@ class _HistoryScreenState extends State { _load(); } + int? _parseTaskIdInt(String raw) { + final t = raw.trim(); + if (t.isEmpty) return null; + return int.tryParse(t); + } + + /// 调用 [ImageApi.deleteTask];成功后从列表与本地封面映射移除。 + Future _confirmAndDeleteTask(MyTaskItem item) async { + final idStr = item.taskId?.trim() ?? ''; + final taskId = _parseTaskIdInt(idStr); + if (taskId == null) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Invalid task id'))); + return; + } + final confirmed = await showPencilWhiteDangerConfirmDialog( + context, + title: 'Confirm deletion', + body: 'This item will be removed from your history. Continue?', + ); + if (!confirmed || !mounted) return; + if (_deletingTaskIds.contains(idStr)) return; + setState(() => _deletingTaskIds.add(idStr)); + dynamic res; + try { + res = await ImageApi.deleteTask(taskId: taskId); + } catch (_) { + res = null; + } finally { + if (mounted) { + setState(() => _deletingTaskIds.remove(idStr)); + } + } + if (!mounted) return; + if (res == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Delete failed. Please try again.')), + ); + return; + } + if (res.isSuccess != true) { + final msg = '${res.msg ?? ''}'.trim(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(msg.isNotEmpty ? msg : 'Delete failed')), + ); + return; + } + setState(() { + _items = _items + .where((e) => int.tryParse(e.taskId?.trim() ?? '') != taskId) + .toList(); + _localCovers.removeWhere( + (k, _) => k.trim() == idStr || int.tryParse(k.trim()) == taskId, + ); + }); + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Deleted'))); + } + Future _load() async { setState(() { _loading = true; @@ -108,10 +176,7 @@ class _HistoryScreenState extends State { @override Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - gradient: PencilTheme.yellowWhitePageGradient, - ), + return PencilYellowWhitePageBackground( child: Scaffold( backgroundColor: Colors.transparent, body: SafeArea( @@ -123,8 +188,7 @@ class _HistoryScreenState extends State { child: SizedBox( height: 58, child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( children: [ PencilRoundCloseButton( @@ -216,10 +280,7 @@ class _HistoryScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text( - _error ?? 'Sign in failed', - textAlign: TextAlign.center, - ), + Text(_error ?? 'Sign in failed', textAlign: TextAlign.center), ], ), ), @@ -258,8 +319,10 @@ class _HistoryScreenState extends State { child: Padding( padding: EdgeInsets.only(bottom: shellBottom), child: Center( - child: Text('No tasks yet.', - style: GoogleFonts.inter(color: PencilTheme.inkSoft)), + child: Text( + 'No tasks yet.', + style: GoogleFonts.inter(color: PencilTheme.inkSoft), + ), ), ), ) @@ -273,87 +336,88 @@ class _HistoryScreenState extends State { crossAxisSpacing: 14, childAspectRatio: 171 / 182, ), - delegate: SliverChildBuilderDelegate( - (context, i) { - final t = _items[i]; - final id = t.taskId ?? ''; - final raw = myTaskListingRaw(t); - final display = listingDisplayFromApi(raw); - final canDl = myTaskCanShowDownload(t); - final statusLabel = myTaskStatusLabel(t); - return HistoryGridCard( - item: t, - localCoverPath: - id.isEmpty ? null : _localCovers[id], - showDownload: canDl, - statusLabel: statusLabel, - isDownloading: - id.isNotEmpty && _downloadingTaskIds.contains(id), - onTap: () { - if (id.isEmpty) return; - // 有结果 URL 优先进预览(与列表「完成」态以地址为准一致) - if (myTaskHasRemoteResultUrl(t)) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => GenerateResultScreen( - taskId: id, - resultUrl: t.resultUrl?.trim() ?? '', - ), - ), - ); - return; - } - if (myTaskIsInProgress(t)) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - HistoryTaskProgressScreen(taskId: id), - ), - ); - return; - } - if (galleryListingIsFinishedSuccess(raw, display)) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Media is not ready yet. Pull to refresh.', - ), - ), - ); - return; - } - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - galleryListingBlockedHint(raw, display), + delegate: SliverChildBuilderDelegate((context, i) { + final t = _items[i]; + final id = t.taskId ?? ''; + final raw = myTaskListingRaw(t); + final display = listingDisplayFromApi(raw); + final canDl = myTaskCanShowDownload(t); + final statusLabel = myTaskStatusLabel(t); + return HistoryGridCard( + item: t, + localCoverPath: id.isEmpty ? null : _localCovers[id], + showDownload: canDl, + statusLabel: statusLabel, + isDownloading: + id.isNotEmpty && _downloadingTaskIds.contains(id), + isDeleting: + id.isNotEmpty && _deletingTaskIds.contains(id), + onDelete: id.isEmpty + ? null + : () { + unawaited(_confirmAndDeleteTask(t)); + }, + onTap: () { + if (id.isEmpty) return; + // 有结果 URL 优先进预览(与列表「完成」态以地址为准一致) + if (myTaskHasRemoteResultUrl(t)) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => GenerateResultScreen( + taskId: id, + resultUrl: t.resultUrl?.trim() ?? '', ), ), ); - }, - onDownload: canDl - ? () async { - final u = t.resultUrl?.trim() ?? ''; - if (u.isEmpty || id.isEmpty) return; - setState(() => _downloadingTaskIds.add(id)); - try { - await saveHistoryMediaToGallery( - context: context, - taskId: id, - resultUrl: u, - ); - } finally { - if (mounted) { - setState( - () => _downloadingTaskIds.remove(id), - ); - } + return; + } + if (myTaskIsInProgress(t)) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + HistoryTaskProgressScreen(taskId: id), + ), + ); + return; + } + if (galleryListingIsFinishedSuccess(raw, display)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Media is not ready yet. Pull to refresh.', + ), + ), + ); + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + galleryListingBlockedHint(raw, display), + ), + ), + ); + }, + onDownload: canDl + ? () async { + final u = t.resultUrl?.trim() ?? ''; + if (u.isEmpty || id.isEmpty) return; + setState(() => _downloadingTaskIds.add(id)); + try { + await saveHistoryMediaToGallery( + context: context, + taskId: id, + resultUrl: u, + ); + } finally { + if (mounted) { + setState(() => _downloadingTaskIds.remove(id)); } } - : null, - ); - }, - childCount: _items.length, - ), + } + : null, + ); + }, childCount: _items.length), ), ), ], diff --git a/lib/features/history/history_task_progress_screen.dart b/lib/features/history/history_task_progress_screen.dart index a793fd5..48215f8 100644 --- a/lib/features/history/history_task_progress_screen.dart +++ b/lib/features/history/history_task_progress_screen.dart @@ -6,6 +6,7 @@ import '../../core/app_env.dart'; import '../../core/user/user_state.dart'; import '../../design/pencil_theme.dart'; import '../../widgets/pencil_chrome.dart'; +import '../../widgets/pencil_yellow_white_background.dart'; import '../generate/generate_result_screen.dart'; /// 从 My History 点「生成中」任务进入:轮询 [ImageApi.getProgress],完成后进入 [GenerateResultScreen]。 @@ -98,10 +99,7 @@ class _HistoryTaskProgressScreenState extends State { @override Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - gradient: PencilTheme.yellowWhitePageGradient, - ), + return PencilYellowWhitePageBackground( child: Scaffold( backgroundColor: Colors.transparent, body: SafeArea( diff --git a/lib/features/history/widgets/history_grid_card.dart b/lib/features/history/widgets/history_grid_card.dart index 00714da..761cd08 100644 --- a/lib/features/history/widgets/history_grid_card.dart +++ b/lib/features/history/widgets/history_grid_card.dart @@ -20,15 +20,18 @@ class HistoryGridCard extends StatelessWidget { this.localCoverPath, this.onTap, this.onDownload, + this.onDelete, this.showDownload = true, this.statusLabel = '', this.isDownloading = false, + this.isDeleting = false, }); final MyTaskItem item; final String? localCoverPath; final VoidCallback? onTap; final VoidCallback? onDownload; + final VoidCallback? onDelete; /// 仅完成态且可下载时显示 Download;否则展示 [statusLabel](与 app_client 图库一致)。 final bool showDownload; @@ -36,6 +39,7 @@ class HistoryGridCard extends StatelessWidget { /// 保存到相册进行中:pill 显示加载,直至 [onDownload] 结束。 final bool isDownloading; + final bool isDeleting; @override Widget build(BuildContext context) { @@ -93,45 +97,85 @@ class HistoryGridCard extends StatelessWidget { ), ), Positioned( - left: 10, - top: 10, - right: 10, - child: Column( + left: 8, + top: 6, + right: 6, + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - dateLabel, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: GoogleFonts.inter( - fontSize: 9, - fontWeight: FontWeight.w500, - color: Colors.white.withValues(alpha: 0.95), - shadows: const [ - Shadow( - blurRadius: 4, - color: Color(0x40000000), - offset: Offset(0, 1), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + dateLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w500, + color: Colors.white.withValues(alpha: 0.95), + shadows: const [ + Shadow( + blurRadius: 4, + color: Color(0x40000000), + offset: Offset(0, 1), + ), + ], + ), + ), + const SizedBox(height: 2), + Text( + remainder, + style: GoogleFonts.inter( + fontSize: 9, + fontWeight: FontWeight.w600, + color: PencilTheme.underlineGold, + shadows: const [ + Shadow( + blurRadius: 4, + color: Color(0x40000000), + offset: Offset(0, 1), + ), + ], + ), ), ], ), ), - const SizedBox(height: 2), - Text( - remainder, - style: GoogleFonts.inter( - fontSize: 9, - fontWeight: FontWeight.w600, - color: PencilTheme.underlineGold, - shadows: const [ - Shadow( - blurRadius: 4, - color: Color(0x40000000), - offset: Offset(0, 1), + if (onDelete != null) ...[ + const SizedBox(width: 4), + Material( + color: Colors.black.withValues(alpha: 0.4), + shape: const CircleBorder(), + child: IconButton( + onPressed: isDeleting ? null : onDelete, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + minWidth: 30, + minHeight: 30, ), - ], + icon: isDeleting + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white.withValues(alpha: 0.95), + ), + ) + : Icon( + Icons.delete_outline_rounded, + size: 17, + color: Colors.white.withValues(alpha: 0.95), + ), + style: IconButton.styleFrom( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), ), - ), + ], ], ), ), diff --git a/lib/features/profile/delete_account_flow.dart b/lib/features/profile/delete_account_flow.dart index c25a00a..d1b2caf 100644 --- a/lib/features/profile/delete_account_flow.dart +++ b/lib/features/profile/delete_account_flow.dart @@ -467,3 +467,132 @@ class _DialogPrimaryButton extends StatelessWidget { ); } } + +/// 白底危险确认弹窗(与 [_DeleteAccountStep1Dialog] 同一套圆角、描边、阴影与底部双按钮)。 +/// +/// 返回 `true` 表示用户点击主按钮(默认「确定」)。 +Future showPencilWhiteDangerConfirmDialog( + BuildContext context, { + required String title, + required String body, + String cancelLabel = 'Cancel', + String confirmLabel = 'Confirm', + IconData icon = Icons.delete_outline_rounded, + bool barrierDismissible = true, +}) async { + final r = await showDialog( + context: context, + barrierDismissible: barrierDismissible, + barrierColor: _kScrim, + builder: (ctx) => _PencilWhiteDangerConfirmBody( + title: title, + body: body, + cancelLabel: cancelLabel, + confirmLabel: confirmLabel, + icon: icon, + ), + ); + return r ?? false; +} + +class _PencilWhiteDangerConfirmBody extends StatelessWidget { + const _PencilWhiteDangerConfirmBody({ + required this.title, + required this.body, + required this.cancelLabel, + required this.confirmLabel, + required this.icon, + }); + + final String title; + final String body; + final String cancelLabel; + final String confirmLabel; + final IconData icon; + + @override + Widget build(BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _kModalWidth), + child: Container( + padding: const EdgeInsets.all(22), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: _kModalBorder), + boxShadow: const [ + BoxShadow( + color: Color(0x18000000), + blurRadius: 28, + offset: Offset(0, 12), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: _kDangerIconBg, + borderRadius: BorderRadius.circular(999), + ), + child: Icon( + icon, + size: 26, + color: _kDanger, + ), + ), + ), + const SizedBox(height: 16), + Text( + title, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w700, + color: _kTitle, + ), + ), + const SizedBox(height: 12), + Text( + body, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w500, + height: 1.45, + color: _kBody, + ), + ), + const SizedBox(height: 22), + Row( + children: [ + Expanded( + child: _DialogSecondaryButton( + label: cancelLabel, + onTap: () => Navigator.of(context).pop(false), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _DialogPrimaryButton( + label: confirmLabel, + enabled: true, + onTap: () => Navigator.of(context).pop(true), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/profile/profile_screen.dart b/lib/features/profile/profile_screen.dart index cfd6579..595ea3a 100644 --- a/lib/features/profile/profile_screen.dart +++ b/lib/features/profile/profile_screen.dart @@ -10,6 +10,7 @@ import '../../core/ext_config_document_urls.dart'; import '../../core/user/user_state.dart'; import '../../design/pencil_theme.dart'; import '../../widgets/pencil_chrome.dart'; +import '../../widgets/pencil_yellow_white_background.dart'; import '../web/app_web_view_screen.dart'; import 'delete_account_flow.dart'; @@ -37,10 +38,7 @@ class _ProfileScreenState extends State { @override Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - gradient: PencilTheme.yellowWhitePageGradient, - ), + return PencilYellowWhitePageBackground( child: Scaffold( backgroundColor: Colors.transparent, body: SafeArea( diff --git a/lib/features/purchase/purchase_screen.dart b/lib/features/purchase/purchase_screen.dart index 07f9710..95ae262 100644 --- a/lib/features/purchase/purchase_screen.dart +++ b/lib/features/purchase/purchase_screen.dart @@ -10,7 +10,9 @@ import '../../core/payment/google_play_order_recovery.dart'; import '../../core/user/user_state.dart'; import '../../design/pencil_theme.dart'; import '../../widgets/pencil_chrome.dart'; +import '../../widgets/pencil_yellow_white_background.dart'; import '../web/app_web_view_screen.dart'; +import 'third_party_payment_sheet.dart'; /// `ETbdo` Purchase Point:黄渐变、Bonheur 标题、积分区 + [xiabiao]、双列档位 + [credit_tag] 角标。 /// 商品来自 [PaymentFlowCatalog.loadStoreActivities]。 @@ -101,107 +103,14 @@ class _PurchaseScreenState extends State Future _pickPaymentMethod( List methods, + PaymentProductItem product, ) { - return showModalBottomSheet( - context: context, - backgroundColor: Colors.white, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (ctx) { - return SafeArea( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - Center( - child: Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: PencilTheme.genNavBackStroke, - borderRadius: BorderRadius.circular(2), - ), - ), - ), - const SizedBox(height: 16), - Text( - 'Payment method', - textAlign: TextAlign.center, - style: GoogleFonts.inter( - fontSize: 17, - fontWeight: FontWeight.w700, - color: PencilTheme.stone900, - ), - ), - const SizedBox(height: 12), - for (var i = 0; i < methods.length; i++) ...[ - if (i > 0) - Divider( - height: 1, - thickness: 1, - color: PencilTheme.genNavBackStroke, - ), - ListTile( - contentPadding: EdgeInsets.zero, - minLeadingWidth: _PaymentMethodSheetIcon.slotWidth + 8, - leading: _PaymentMethodSheetIcon( - iconUrl: methods[i].icon, - ), - title: Row( - children: [ - Expanded( - child: Text( - methods[i].displayName.isNotEmpty - ? methods[i].displayName - : (methods[i].paymentMethod ?? 'Payment'), - style: GoogleFonts.inter( - fontWeight: FontWeight.w600, - color: PencilTheme.stone900, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (methods[i].recommend == true) ...[ - const SizedBox(width: 6), - Text( - 'Recommended', - style: GoogleFonts.inter( - fontSize: 11, - color: PencilTheme.underlineGold, - fontWeight: FontWeight.w600, - ), - ), - ], - ], - ), - subtitle: methods[i].bonusLabel != null - ? Padding( - padding: const EdgeInsets.only(top: 4), - child: Text( - methods[i].bonusLabel!, - style: GoogleFonts.inter( - fontSize: 12, - color: PencilTheme.underlineGold, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ) - : null, - onTap: () => Navigator.pop(ctx, methods[i]), - ), - ], - ], - ), - ), - ), - ); - }, + return showThirdPartyPaymentMethodSheet( + context, + methods: methods, + product: product, + summaryTierCredits: product.credits, + summaryTierBonus: product.bonus, ); } @@ -261,7 +170,7 @@ class _PurchaseScreenState extends State _paying = false; }); - final picked = await _pickPaymentMethod(methods); + final picked = await _pickPaymentMethod(methods, item); if (!mounted || picked == null) { _resetPayingState(); return; @@ -456,10 +365,7 @@ class _PurchaseScreenState extends State @override Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - gradient: PencilTheme.yellowWhitePageGradient, - ), + return PencilYellowWhitePageBackground( child: Scaffold( backgroundColor: Colors.transparent, body: SafeArea( @@ -781,20 +687,10 @@ class _ProductCard extends StatelessWidget { static final _money = RegExp(r'[\d.]+'); - /// [PaymentProductItem.bonus] + [PaymentProductItem.bonusCredits](换皮线网 `contrast` + `saturation`)。 - static String? _bonusDisplayLine({ - required int base, - required int gift, - required int total, - }) { + /// 赠送展示:仅 [PaymentProductItem.bonus](线网常为 `contrast` 映射),`+N bonus`。 + static String? _bonusDisplayLine(int total) { if (total <= 0) return null; - if (gift > 0 && base > 0) { - return '+$total bonus (incl. $gift gift)'; - } - if (gift > 0) { - return '+$gift gift credits'; - } - return '+$base bonus'; + return '+$total bonus'; } static int? _discountPercent(String? actual, String? origin) { @@ -824,14 +720,7 @@ class _ProductCard extends StatelessWidget { : 'Credits:—'; final actual = item.actualAmount ?? '—'; final origin = item.originAmount; - final bonusBase = item.bonus ?? 0; - final bonusGift = item.bonusCredits ?? 0; - final bonusTotal = bonusBase + bonusGift; - final bonusLine = _bonusDisplayLine( - base: bonusBase, - gift: bonusGift, - total: bonusTotal, - ); + final bonusLine = _bonusDisplayLine(item.bonus ?? 0); final pct = _discountPercent(item.actualAmount, item.originAmount); return Material( @@ -961,36 +850,3 @@ class _ProductCard extends StatelessWidget { } } -/// 支付方式图标:与 app_client [_PaymentIcon] 一致用 [BoxFit.contain] 完整显示、不变形; -/// 使用横向矩形槽位以贴近常见支付 Logo 比例(相对 40×40 裁切更自然)。 -class _PaymentMethodSheetIcon extends StatelessWidget { - const _PaymentMethodSheetIcon({this.iconUrl}); - - final String? iconUrl; - - /// 横向略宽,给长方形原图留足空间(高度与 app_client 40 一致)。 - static const double slotWidth = 56; - static const double slotHeight = 40; - - @override - Widget build(BuildContext context) { - final url = iconUrl?.trim(); - final fallback = Icon( - Icons.payment_outlined, - size: 24, - color: PencilTheme.underlineGold, - ); - return SizedBox( - width: slotWidth, - height: slotHeight, - child: url != null && url.isNotEmpty - ? Image.network( - url, - fit: BoxFit.contain, - alignment: Alignment.center, - errorBuilder: (_, _, _) => Center(child: fallback), - ) - : Center(child: fallback), - ); - } -} diff --git a/lib/features/purchase/third_party_payment_sheet.dart b/lib/features/purchase/third_party_payment_sheet.dart new file mode 100644 index 0000000..8212d28 --- /dev/null +++ b/lib/features/purchase/third_party_payment_sheet.dart @@ -0,0 +1,551 @@ +import 'package:client_proxy_framework/client_proxy_framework.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../design/pencil_theme.dart'; + +/// 本页「图标」相对设计稿的缩放(支付方式 logo、摘要钻石等)。 +const double _paymentSheetIconScale = 1.5; + +/// 与 `funymee_home.pen` **zL8hY / p7kQm** 对齐的第三方支付底部表单。 +/// 先点选支付方式,再点底部 **Pay** 确认(与画板 `btnPayPrimary` 一致)。 +/// +/// [summaryTierCredits] / [summaryTierBonus] 与购买页档位一致(原始档位积分 + 原始档位赠送); +/// 均为 `null` 时从 [product] 读取。 +Future showThirdPartyPaymentMethodSheet( + BuildContext context, { + required List methods, + required PaymentProductItem product, + int? summaryTierCredits, + int? summaryTierBonus, +}) { + if (methods.isEmpty) return Future.value(null); + + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + // 使用框架 ModalBarrier 遮罩,避免与下层页面视觉上糊在一起。 + barrierColor: PencilTheme.paymentModalDimScrim, + isDismissible: true, + builder: (ctx) { + final w = MediaQuery.sizeOf(ctx).width; + final sheetW = w < PencilTheme.designWidth ? w : PencilTheme.designWidth; + return Align( + alignment: Alignment.bottomCenter, + child: SizedBox( + width: sheetW, + child: _ThirdPartyPaymentSheetBody( + methods: methods, + product: product, + summaryTierCredits: summaryTierCredits, + summaryTierBonus: summaryTierBonus, + ), + ), + ); + }, + ); +} + +class _ThirdPartyPaymentSheetBody extends StatefulWidget { + const _ThirdPartyPaymentSheetBody({ + required this.methods, + required this.product, + this.summaryTierCredits, + this.summaryTierBonus, + }); + + final List methods; + final PaymentProductItem product; + + /// 购买页传入的档位积分;`null` 则用 [product.credits]。 + final int? summaryTierCredits; + + /// 购买页传入的档位赠送;`null` 则用 [product.bonus]。 + final int? summaryTierBonus; + + @override + State<_ThirdPartyPaymentSheetBody> createState() => + _ThirdPartyPaymentSheetBodyState(); +} + +class _ThirdPartyPaymentSheetBodyState + extends State<_ThirdPartyPaymentSheetBody> { + late int _selectedIndex; + + @override + void initState() { + super.initState(); + _selectedIndex = 0; + } + + String get _payLabel => 'Pay Now'; + + @override + Widget build(BuildContext context) { + final bottomSafe = MediaQuery.paddingOf(context).bottom; + final bottomPad = bottomSafe < 34 ? 34.0 : bottomSafe.toDouble(); + + const sheetRadius = BorderRadius.only( + topLeft: Radius.circular(24), + topRight: Radius.circular(24), + ); + + return Container( + decoration: BoxDecoration( + borderRadius: sheetRadius, + boxShadow: PencilTheme.paymentSheetOuterShadow, + ), + child: ClipRRect( + borderRadius: sheetRadius, + clipBehavior: Clip.antiAlias, + child: Container( + foregroundDecoration: BoxDecoration( + borderRadius: sheetRadius, + border: const Border( + top: BorderSide(color: PencilTheme.genHintBorder, width: 1), + left: BorderSide(color: PencilTheme.genHintBorder, width: 1), + right: BorderSide(color: PencilTheme.genHintBorder, width: 1), + ), + ), + child: Stack( + fit: StackFit.loose, + children: [ + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: PencilTheme.paymentSheetBodyGradient, + ), + ), + ), + Positioned.fill( + child: Opacity( + opacity: 0.2, + child: Image.asset( + 'assets/images/checker_20px_500.png', + fit: BoxFit.cover, + alignment: Alignment.topCenter, + filterQuality: FilterQuality.medium, + ), + ), + ), + Material( + color: Colors.transparent, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(20, 10, 20, bottomPad), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 5, + decoration: BoxDecoration( + color: PencilTheme.paymentRadioOffStroke, + borderRadius: BorderRadius.circular(99), + ), + ), + ), + const SizedBox(height: 18), + Text( + 'Payment Methods', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 20, + fontWeight: FontWeight.w700, + color: PencilTheme.stone900, + height: 1.2, + ), + ), + const SizedBox(height: 18), + _PaymentSummaryCard( + product: widget.product, + tierCredits: + widget.summaryTierCredits ?? widget.product.credits, + tierBonus: + widget.summaryTierBonus ?? widget.product.bonus, + ), + const SizedBox(height: 18), + Text( + 'Payment', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: PencilTheme.stone600, + height: 1.2, + ), + ), + const SizedBox(height: 12), + for (var i = 0; i < widget.methods.length; i++) ...[ + if (i > 0) const SizedBox(height: 12), + _PaymentMethodOptionCard( + method: widget.methods[i], + tierCreditsForChannelPercent: + widget.summaryTierCredits ?? + widget.product.credits ?? + 0, + selected: _selectedIndex == i, + iconColor: _iconAccent(widget.methods[i]), + onSelect: () => setState(() => _selectedIndex = i), + ), + ], + const SizedBox(height: 18), + _PayCtaButton( + label: _payLabel, + onPressed: () => Navigator.pop( + context, + widget.methods[_selectedIndex], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + static Color _iconAccent(PaymentMethodItem m) { + final n = m.displayName.toLowerCase(); + if (n.contains('app store')) { + return PencilTheme.paymentAppStoreIconBlue; + } + return PencilTheme.stone900; + } +} + +String _formatProductPrice(PaymentProductItem p) { + final a = p.actualAmount?.trim(); + if (a == null || a.isEmpty) return '\$—'; + if (a.startsWith(r'$') || a.startsWith('¥')) return a; + return '\$$a'; +} + +/// 支付渠道赠送:仅用 [PaymentMethodItem.bonusCredits],相对档位原始积分 [tierCredits] 换算为 `+N% More Credits`(过小为 `+<1% More Credits`)。 +String? _channelBonusCreditsPercentLine( + int tierCredits, + int? channelBonusCredits, +) { + if (channelBonusCredits == null || channelBonusCredits <= 0) return null; + if (tierCredits <= 0) return null; + final rounded = ((channelBonusCredits * 100.0) / tierCredits).round(); + if (rounded > 0) return '+$rounded% More Credits'; + return '+<1% More Credits'; +} + +class _PaymentSummaryCard extends StatelessWidget { + const _PaymentSummaryCard({ + required this.product, + required this.tierCredits, + required this.tierBonus, + }); + + final PaymentProductItem product; + final int? tierCredits; + final int? tierBonus; + + @override + Widget build(BuildContext context) { + final credits = tierCredits ?? 0; + final tierGift = tierBonus; + final price = _formatProductPrice(product); + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: PencilTheme.genNavBackStroke), + boxShadow: PencilTheme.paymentSummaryCardShadow, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.diamond_outlined, + size: 22 * _paymentSheetIconScale, + color: PencilTheme.paymentSummaryGem, + ), + const SizedBox(width: 10), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '$credits', + style: GoogleFonts.inter( + fontSize: 17, + fontWeight: FontWeight.w700, + color: PencilTheme.stone900, + ), + ), + if (tierGift != null && tierGift > 0) ...[ + const SizedBox(width: 4), + Text( + '+$tierGift', + style: GoogleFonts.inter( + fontSize: 17, + fontWeight: FontWeight.w700, + color: PencilTheme.paymentSummaryBonusGreen, + ), + ), + ], + const SizedBox(width: 4), + Text( + 'Credits', + style: GoogleFonts.inter( + fontSize: 14, + fontWeight: FontWeight.w600, + color: PencilTheme.paymentSummaryCreditsLabel, + ), + ), + ], + ), + ), + Text( + price, + style: GoogleFonts.inter( + fontSize: 24, + fontWeight: FontWeight.w800, + color: PencilTheme.stone900, + ), + ), + ], + ), + ); + } +} + +class _PayCtaButton extends StatelessWidget { + const _PayCtaButton({required this.label, required this.onPressed}); + + final String label; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Container( + height: 52, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + gradient: PencilTheme.paymentPayButtonGradient, + boxShadow: PencilTheme.paymentPayButtonShadow, + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(999), + child: Center( + child: Text( + label, + style: GoogleFonts.inter( + fontSize: 17, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ), + ); + } +} + +class _PaymentMethodOptionCard extends StatelessWidget { + const _PaymentMethodOptionCard({ + required this.method, + required this.tierCreditsForChannelPercent, + required this.selected, + required this.iconColor, + required this.onSelect, + }); + + final PaymentMethodItem method; + + /// 渠道赠送占比分母:与摘要一致的档位原始积分(非赠送)。 + final int tierCreditsForChannelPercent; + final bool selected; + final Color iconColor; + final VoidCallback onSelect; + + static const double _radioSize = 22; + static const double _iconBox = 28 * _paymentSheetIconScale; + + @override + Widget build(BuildContext context) { + final title = method.displayName.isNotEmpty + ? method.displayName + : (method.paymentMethod ?? 'Payment'); + final channelBonusLine = _channelBonusCreditsPercentLine( + tierCreditsForChannelPercent, + method.bonusCredits, + ); + + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onSelect, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: selected + ? PencilTheme.paymentMethodSelectedStroke + : PencilTheme.genNavBackStroke, + width: selected ? 2 : 1, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: _radioSize, + height: _radioSize, + child: selected + ? Stack( + alignment: Alignment.center, + children: [ + Container( + width: _radioSize, + height: _radioSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: PencilTheme.paymentMethodSelectedStroke, + border: Border.all( + color: PencilTheme.paymentMethodSelectedStroke, + width: 2, + ), + ), + ), + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + ), + ), + ], + ) + : Container( + width: _radioSize, + height: _radioSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + border: Border.all( + color: PencilTheme.paymentRadioOffStroke, + width: 2, + ), + ), + ), + ), + const SizedBox(width: 12), + SizedBox( + width: _iconBox, + height: _iconBox, + child: _PaymentMethodSheetIconSmall( + iconUrl: method.icon, + color: iconColor, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + color: PencilTheme.stone900, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (channelBonusLine != null) ...[ + const SizedBox(height: 4), + Text( + channelBonusLine, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: PencilTheme.profileCredits, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + if (method.recommend == true) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: PencilTheme.cardThumbBg, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Recommend', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: PencilTheme.stone700, + ), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _PaymentMethodSheetIconSmall extends StatelessWidget { + const _PaymentMethodSheetIconSmall({required this.color, this.iconUrl}); + + final String? iconUrl; + final Color color; + + @override + Widget build(BuildContext context) { + final url = iconUrl?.trim(); + final fallback = Icon( + Icons.payment_outlined, + size: 22 * _paymentSheetIconScale, + color: color, + ); + if (url != null && url.isNotEmpty) { + final img = Image.network( + url, + fit: BoxFit.contain, + alignment: Alignment.center, + errorBuilder: (_, _, _) => Center(child: fallback), + ); + if (color == PencilTheme.paymentAppStoreIconBlue) { + return ColorFiltered( + colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + child: img, + ); + } + return img; + } + return Center(child: fallback); + } +} diff --git a/lib/features/report/report_feedback_upload.dart b/lib/features/report/report_feedback_upload.dart index 77a302b..e4e24bb 100644 --- a/lib/features/report/report_feedback_upload.dart +++ b/lib/features/report/report_feedback_upload.dart @@ -15,6 +15,7 @@ Future uploadFeedbackAttachment(File file) async { ); } final p = presignedRes.data!; + await ImageUploadExpectedSizeCache.writeFeedbackExpectedSize(p.expectedSize); final uploadUrl = p.uploadUrl; final filePath = p.filePath; if (uploadUrl == null || diff --git a/lib/features/report/report_screen.dart b/lib/features/report/report_screen.dart index 8ad089e..4c93600 100644 --- a/lib/features/report/report_screen.dart +++ b/lib/features/report/report_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:client_proxy_framework/client_proxy_framework.dart'; @@ -7,7 +8,9 @@ import 'package:image_picker/image_picker.dart'; import '../../core/auth/auth_service.dart'; import '../../design/pencil_theme.dart'; +import '../../widgets/image_upload_feedback_snackbars.dart'; import '../../widgets/pencil_chrome.dart'; +import '../../widgets/pencil_yellow_white_background.dart'; import 'report_feedback_upload.dart'; /// Report / feedback screen. @@ -28,10 +31,51 @@ class _ReportScreenState extends State { final _picker = ImagePicker(); File? _imageFile; bool _submitting = false; + bool _pickImageLoading = false; + + /// 来自本地缓存的 [FeedbackUploadPresignedUrlResponse.expectedSize];无缓存时为 [ImageUploadExpectedSizeCache.fallbackMaxBytes]。 + int _maxFeedbackImageBytes = ImageUploadExpectedSizeCache.fallbackMaxBytes; /// Logical `contentType` for [FeedbackApi.submit] (maps via `fieldMapping` when sent). static const _feedbackContentType = 'report'; + @override + void initState() { + super.initState(); + unawaited(_refreshFeedbackUploadLimitFromCache()); + } + + Future _refreshFeedbackUploadLimitFromCache() async { + final v = await ImageUploadExpectedSizeCache.readFeedbackMaxBytesForUi(); + if (!mounted) return; + setState(() => _maxFeedbackImageBytes = v); + if (!mounted) return; + await _pruneFeedbackImageIfOverLimit(); + } + + Future _pruneFeedbackImageIfOverLimit() async { + final f = _imageFile; + final maxB = _maxFeedbackImageBytes; + if (f == null || maxB <= 0) return; + if (await f.length() <= maxB) return; + if (!mounted) return; + setState(() => _imageFile = null); + showImageClearedOverLimitSnackBar(context); + } + + String _formatMaxUploadLabel(int bytes) { + if (bytes <= 0) return 'Max — per image'; + if (bytes >= 1024 * 1024) { + final mb = bytes / (1024 * 1024); + final s = mb >= 10 ? mb.toStringAsFixed(0) : mb.toStringAsFixed(1); + return 'Max $s MB per image'; + } + if (bytes >= 1024) { + return 'Max ${(bytes / 1024).round()} KB per image'; + } + return 'Max $bytes bytes per image'; + } + @override void dispose() { _controller.dispose(); @@ -39,15 +83,33 @@ class _ReportScreenState extends State { } Future _pickImage() async { - if (_submitting) return; - final x = await AuthService.runWithNativeMediaPicker( - () => _picker.pickImage( - source: ImageSource.gallery, - imageQuality: 85, - ), - ); - if (x == null || !mounted) return; - setState(() => _imageFile = File(x.path)); + if (_submitting || _pickImageLoading) return; + setState(() => _pickImageLoading = true); + try { + final x = await AuthService.runWithNativeMediaPicker( + () => _picker.pickImage( + source: ImageSource.gallery, + imageQuality: 85, + ), + ); + if (x == null || !mounted) return; + final f = File(x.path); + final len = await f.length(); + final maxB = + await ImageUploadExpectedSizeCache.readFeedbackMaxBytesForUi(); + if (!mounted) return; + if (len > maxB) { + if (!mounted) return; + showImageExceedsMaxUploadSnackBar(context); + return; + } + setState(() { + if (maxB != _maxFeedbackImageBytes) _maxFeedbackImageBytes = maxB; + _imageFile = f; + }); + } finally { + if (mounted) setState(() => _pickImageLoading = false); + } } void _clearImage() { @@ -67,6 +129,14 @@ class _ReportScreenState extends State { try { final urls = []; if (_imageFile != null) { + final len = await _imageFile!.length(); + final maxB = await ImageUploadExpectedSizeCache.readFeedbackMaxBytesForUi(); + if (!mounted) return; + if (len > maxB) { + if (!mounted) return; + showImageExceedsMaxUploadSnackBar(context); + return; + } final path = await uploadFeedbackAttachment(_imageFile!); urls.add(path); } @@ -106,10 +176,8 @@ class _ReportScreenState extends State { @override Widget build(BuildContext context) { - return Container( - decoration: const BoxDecoration( - gradient: PencilTheme.yellowWhitePageGradient, - ), + final feedbackMaxLabel = _formatMaxUploadLabel(_maxFeedbackImageBytes); + return PencilYellowWhitePageBackground( child: Scaffold( backgroundColor: Colors.transparent, body: SafeArea( @@ -239,6 +307,16 @@ class _ReportScreenState extends State { color: PencilTheme.stone900, ), ), + const SizedBox(height: 4), + Text( + feedbackMaxLabel, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1.35, + color: PencilTheme.stone600, + ), + ), const SizedBox(height: 10), Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -246,7 +324,9 @@ class _ReportScreenState extends State { Material( color: Colors.transparent, child: InkWell( - onTap: _submitting ? null : _pickImage, + onTap: (_submitting || _pickImageLoading) + ? null + : _pickImage, borderRadius: BorderRadius.circular(14), child: Ink( width: 112, @@ -258,23 +338,60 @@ class _ReportScreenState extends State { color: PencilTheme.genNavBackStroke, ), ), - child: _imageFile == null - ? Icon( - Icons.add_photo_alternate_outlined, - size: 36, - color: PencilTheme.stone600.withValues( - alpha: 0.7, - ), - ) - : ClipRRect( - borderRadius: BorderRadius.circular(12), - child: Image.file( - _imageFile!, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, + child: Stack( + fit: StackFit.expand, + children: [ + _imageFile == null + ? Center( + child: Icon( + Icons.add_photo_alternate_outlined, + size: 36, + color: PencilTheme.stone600 + .withValues(alpha: 0.7), + ), + ) + : ClipRRect( + borderRadius: + BorderRadius.circular(12), + child: Image.file( + _imageFile!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ), + if (_pickImageLoading) + ColoredBox( + color: + Colors.white.withValues(alpha: 0.88), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 28, + height: 28, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: PencilTheme + .profileAvatarIcon, + ), + ), + const SizedBox(height: 8), + Text( + 'Loading…', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w800, + color: PencilTheme.stone700, + ), + ), + ], + ), ), ), + ], + ), ), ), ), diff --git a/lib/widgets/image_upload_feedback_snackbars.dart b/lib/widgets/image_upload_feedback_snackbars.dart new file mode 100644 index 0000000..c2bc5e6 --- /dev/null +++ b/lib/widgets/image_upload_feedback_snackbars.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +/// 选图校验失败:文件超过上传上限(醒目 SnackBar)。 +void showImageExceedsMaxUploadSnackBar(BuildContext context) { + final m = ScaffoldMessenger.of(context); + m.hideCurrentSnackBar(); + m.showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(12, 0, 12, 20), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + backgroundColor: const Color(0xFFB42318), + elevation: 10, + duration: const Duration(seconds: 5), + content: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_rounded, color: Colors.white, size: 28), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Image too large', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w800, + color: Colors.white, + height: 1.2, + letterSpacing: -0.2, + ), + ), + const SizedBox(height: 6), + Text( + 'This file is over the upload size limit. Please choose a smaller image.', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.94), + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + ); +} + +/// 因上限变更,已选图片被清空时的提示。 +void showImageClearedOverLimitSnackBar(BuildContext context) { + final m = ScaffoldMessenger.of(context); + m.hideCurrentSnackBar(); + m.showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.fromLTRB(12, 0, 12, 20), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + backgroundColor: const Color(0xFFC2410C), + elevation: 10, + duration: const Duration(seconds: 5), + content: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline_rounded, color: Colors.white, size: 26), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Selection cleared', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w800, + color: Colors.white, + height: 1.2, + ), + ), + const SizedBox(height: 6), + Text( + 'Your previous image(s) were over the new size limit and have been removed.', + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.white.withValues(alpha: 0.94), + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + ); +} diff --git a/lib/widgets/pencil_chrome.dart b/lib/widgets/pencil_chrome.dart index 3a0e4d4..72e86a4 100644 --- a/lib/widgets/pencil_chrome.dart +++ b/lib/widgets/pencil_chrome.dart @@ -100,7 +100,7 @@ class PencilGlassCreditsPill extends StatelessWidget { } } -/// bi8Au Create Now:与 `desgin/funymee_home.pen` [aHMps] 同步(含半透明、尺寸、左加号、间距)。 +/// bi8Au Create Now:与 `desgin/funymee_home.pen` [aHMps] 同步(白半透明底、描边、阴影;左黄圆 + 黑加号)。 class PencilCreateNowButton extends StatelessWidget { const PencilCreateNowButton({super.key, required this.onPressed}); @@ -110,97 +110,79 @@ class PencilCreateNowButton extends StatelessWidget { static const double _w = 186; static const double _h = 42; - /// `aHMps` opacity - static const double _opacity = 0.88; - - /// `aHMps` gap;横向 padding 取 pen `padding` 中 22;竖直取 `(42-24)/2` 以垂直居中 24×24 左标。 + /// `aHMps` gap;横向 padding 22;竖直 `(42-24)/2` 以垂直居中 24×24 左标。 static const double _gap = 10; static const EdgeInsets _padding = EdgeInsets.symmetric(horizontal: 22, vertical: 9); + /// `aHMps` fill `#FFFFFFA8`(略比 `#FFFFFFB3` 更透);描边 `#FFFFFFCC` 1.5;阴影 `#00000026` y6 blur20。 + static const Color _fill = Color(0xA8FFFFFF); + static const Color _stroke = Color(0xCCFFFFFF); + static const Color _shadow = Color(0x26000000); + + /// `TAocZ` plusCirc — 实心黄 `#FDE047`;`9PFVT` 加号 `#000000`。 + static const Color _plusDisc = Color(0xFFFDE047); + @override Widget build(BuildContext context) { - return Opacity( - opacity: _opacity, + // 勿用 [Ink] 包一层 [BoxDecoration] 圆角底:父级 Material 的墨水层是矩形,半透明会在圆角外露出成「方块」背板。 + // 这里用 [Material.shape] + [clipBehavior] 约束底色与水波纹,外圈 [Container] 只负责投影。 + return Container( + width: _w, + height: _h, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + boxShadow: const [ + BoxShadow( + color: _shadow, + offset: Offset(0, 6), + blurRadius: 20, + ), + ], + ), child: Material( - color: Colors.transparent, + color: _fill, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + side: const BorderSide(color: _stroke, width: 1.5), + ), child: InkWell( onTap: onPressed, borderRadius: BorderRadius.circular(999), - child: Ink( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(999), - gradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Color(0xFFFFFDE7), - Color(0xFFFDE047), - Color(0xFFF59E0B), - ], - stops: [0.0, 0.42, 1.0], - ), - border: Border.all( - color: Color(0xD9FFFFFF), - width: 2, - ), - boxShadow: const [ - BoxShadow( - color: Color(0x52B45309), - offset: Offset(0, 10), - blurRadius: 28, + child: Padding( + padding: _padding, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + DecoratedBox( + decoration: BoxDecoration( + color: _plusDisc, + borderRadius: BorderRadius.circular(20), + ), + child: const SizedBox( + width: 24, + height: 24, + child: Icon( + Icons.add_rounded, + size: 12, + color: Colors.black, + ), + ), + ), + const SizedBox(width: _gap), + Text( + 'Create Now', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w800, + color: PencilTheme.stone900, + letterSpacing: 0.4, + ), ), ], ), - child: SizedBox( - width: _w, - height: _h, - child: Padding( - padding: _padding, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - DecoratedBox( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: Color(0x99F59E0B), - width: 1.5, - ), - boxShadow: const [ - BoxShadow( - color: Color(0x14000000), - offset: Offset(0, 2), - blurRadius: 6, - ), - ], - ), - child: const SizedBox( - width: 24, - height: 24, - child: Icon( - Icons.add_rounded, - size: 12, - color: Color(0xFFB45309), - ), - ), - ), - const SizedBox(width: _gap), - Text( - 'Create Now', - style: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w800, - color: PencilTheme.stone900, - letterSpacing: 0.4, - ), - ), - ], - ), - ), - ), ), ), ), diff --git a/lib/widgets/pencil_yellow_white_background.dart b/lib/widgets/pencil_yellow_white_background.dart new file mode 100644 index 0000000..2a248c4 --- /dev/null +++ b/lib/widgets/pencil_yellow_white_background.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +import '../design/pencil_theme.dart'; + +/// 通用黄→白页背景,与 `funymee_home.pen` **suXxr** 一致,并在顶部叠 **`checker_20px_500.png`**(透明度 **20%**,同 **zL8hY** 设计)。 +class PencilYellowWhitePageBackground extends StatelessWidget { + const PencilYellowWhitePageBackground({super.key, required this.child}); + + final Widget child; + + static const String _checkerAsset = 'assets/images/checker_20px_500.png'; + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + const DecoratedBox( + decoration: BoxDecoration( + gradient: PencilTheme.yellowWhitePageGradient, + ), + ), + Positioned.fill( + child: IgnorePointer( + child: Opacity( + opacity: 0.2, + child: Image.asset( + _checkerAsset, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + filterQuality: FilterQuality.medium, + ), + ), + ), + ), + child, + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 1dbed90..f8ac03c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: funymee_ai description: "FunyMee AI Application." publish_to: 'none' -version: 1.0.15+15 +version: 1.0.16+16 environment: sdk: ^3.11.1