优化:三方支付优化,积分记录优化
This commit is contained in:
parent
07a047f031
commit
53497edc62
11
.cursor/rules/pencil-mcp.mdc
Normal file
11
.cursor/rules/pencil-mcp.mdc
Normal file
@ -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.
|
||||
BIN
assets/images/checker_20px_500.png
Normal file
BIN
assets/images/checker_20px_500.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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<BoxShadow> 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<BoxShadow> 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<BoxShadow> 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,
|
||||
|
||||
@ -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<GenerateScreen> {
|
||||
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<GenerateScreen> {
|
||||
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<GenerateScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncOutputSizeForTemplate();
|
||||
unawaited(_refreshUploadLimitFromCache());
|
||||
}
|
||||
|
||||
Future<void> _refreshUploadLimitFromCache() async {
|
||||
final v = await ImageUploadExpectedSizeCache.readImageMaxBytesForUi();
|
||||
if (!mounted) return;
|
||||
setState(() => _maxUploadBytes = v);
|
||||
if (!mounted) return;
|
||||
await _prunePickedIfOverLimit();
|
||||
}
|
||||
|
||||
Future<void> _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<GenerateScreen> {
|
||||
}
|
||||
|
||||
Future<void> _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<ImageSource?> _showPickImageSourceSheet(BuildContext context) {
|
||||
@ -459,11 +529,7 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
||||
|
||||
@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<GenerateScreen> {
|
||||
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<GenerateScreen> {
|
||||
),
|
||||
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,
|
||||
|
||||
@ -18,16 +18,25 @@ class CreditRecordTab extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CreditRecordTabState extends State<CreditRecordTab> {
|
||||
static const int _pageSize = 30;
|
||||
|
||||
bool _loading = true;
|
||||
bool _loadingMore = false;
|
||||
bool _hasMore = true;
|
||||
String? _error;
|
||||
List<CreditRecordItem> _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<CreditRecordTab> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScrollNearEnd);
|
||||
_scrollController.dispose();
|
||||
_cancelLoginWait?.call();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<CreditRecordTab> {
|
||||
});
|
||||
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<void> _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<CreditRecordTab> {
|
||||
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<CreditRecordTab> {
|
||||
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<CreditRecordTab> {
|
||||
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<CreditRecordTab> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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<HistoryScreen> {
|
||||
List<MyTaskItem> _items = [];
|
||||
Map<String, String> _localCovers = {};
|
||||
final Set<String> _downloadingTaskIds = {};
|
||||
final Set<String> _deletingTaskIds = {};
|
||||
VoidCallback? _cancelLoginWait;
|
||||
|
||||
@override
|
||||
@ -79,6 +84,69 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
_load();
|
||||
}
|
||||
|
||||
int? _parseTaskIdInt(String raw) {
|
||||
final t = raw.trim();
|
||||
if (t.isEmpty) return null;
|
||||
return int.tryParse(t);
|
||||
}
|
||||
|
||||
/// 调用 [ImageApi.deleteTask];成功后从列表与本地封面映射移除。
|
||||
Future<void> _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<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
@ -108,10 +176,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
|
||||
@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<HistoryScreen> {
|
||||
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<HistoryScreen> {
|
||||
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<HistoryScreen> {
|
||||
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<HistoryScreen> {
|
||||
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<void>(
|
||||
builder: (_) => GenerateResultScreen(
|
||||
taskId: id,
|
||||
resultUrl: t.resultUrl?.trim() ?? '',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (myTaskIsInProgress(t)) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
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<void>(
|
||||
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<void>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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<HistoryTaskProgressScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: PencilTheme.yellowWhitePageGradient,
|
||||
),
|
||||
return PencilYellowWhitePageBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -467,3 +467,132 @@ class _DialogPrimaryButton extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 白底危险确认弹窗(与 [_DeleteAccountStep1Dialog] 同一套圆角、描边、阴影与底部双按钮)。
|
||||
///
|
||||
/// 返回 `true` 表示用户点击主按钮(默认「确定」)。
|
||||
Future<bool> 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<bool>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<ProfileScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: PencilTheme.yellowWhitePageGradient,
|
||||
),
|
||||
return PencilYellowWhitePageBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
|
||||
@ -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<PurchaseScreen>
|
||||
|
||||
Future<PaymentMethodItem?> _pickPaymentMethod(
|
||||
List<PaymentMethodItem> methods,
|
||||
PaymentProductItem product,
|
||||
) {
|
||||
return showModalBottomSheet<PaymentMethodItem>(
|
||||
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<PurchaseScreen>
|
||||
_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<PurchaseScreen>
|
||||
|
||||
@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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
551
lib/features/purchase/third_party_payment_sheet.dart
Normal file
551
lib/features/purchase/third_party_payment_sheet.dart
Normal file
@ -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<PaymentMethodItem?> showThirdPartyPaymentMethodSheet(
|
||||
BuildContext context, {
|
||||
required List<PaymentMethodItem> methods,
|
||||
required PaymentProductItem product,
|
||||
int? summaryTierCredits,
|
||||
int? summaryTierBonus,
|
||||
}) {
|
||||
if (methods.isEmpty) return Future.value(null);
|
||||
|
||||
return showModalBottomSheet<PaymentMethodItem>(
|
||||
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<PaymentMethodItem> 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);
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,7 @@ Future<String> uploadFeedbackAttachment(File file) async {
|
||||
);
|
||||
}
|
||||
final p = presignedRes.data!;
|
||||
await ImageUploadExpectedSizeCache.writeFeedbackExpectedSize(p.expectedSize);
|
||||
final uploadUrl = p.uploadUrl;
|
||||
final filePath = p.filePath;
|
||||
if (uploadUrl == null ||
|
||||
|
||||
@ -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<ReportScreen> {
|
||||
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<void> _refreshFeedbackUploadLimitFromCache() async {
|
||||
final v = await ImageUploadExpectedSizeCache.readFeedbackMaxBytesForUi();
|
||||
if (!mounted) return;
|
||||
setState(() => _maxFeedbackImageBytes = v);
|
||||
if (!mounted) return;
|
||||
await _pruneFeedbackImageIfOverLimit();
|
||||
}
|
||||
|
||||
Future<void> _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<ReportScreen> {
|
||||
}
|
||||
|
||||
Future<void> _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<ReportScreen> {
|
||||
try {
|
||||
final urls = <String>[];
|
||||
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<ReportScreen> {
|
||||
|
||||
@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<ReportScreen> {
|
||||
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<ReportScreen> {
|
||||
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<ReportScreen> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
103
lib/widgets/image_upload_feedback_snackbars.dart
Normal file
103
lib/widgets/image_upload_feedback_snackbars.dart
Normal file
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
40
lib/widgets/pencil_yellow_white_background.dart
Normal file
40
lib/widgets/pencil_yellow_white_background.dart
Normal file
@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user