优化:三方支付优化,积分记录优化
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": [
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "frame",
|
"type": "frame",
|
||||||
@ -184,47 +184,25 @@
|
|||||||
"type": "frame",
|
"type": "frame",
|
||||||
"id": "aHMps",
|
"id": "aHMps",
|
||||||
"name": "createBtn",
|
"name": "createBtn",
|
||||||
"opacity": 0.88,
|
"opacity": 1,
|
||||||
"width": 186,
|
"width": 186,
|
||||||
"height": 42,
|
"height": 42,
|
||||||
"fill": {
|
"fill": "#FFFFFFA8",
|
||||||
"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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cornerRadius": 999,
|
"cornerRadius": 999,
|
||||||
"stroke": {
|
"stroke": {
|
||||||
"align": "inside",
|
"align": "inside",
|
||||||
"thickness": 2,
|
"thickness": 1.5,
|
||||||
"fill": "#FFFFFFD9"
|
"fill": "#FFFFFFCC"
|
||||||
},
|
},
|
||||||
"effect": {
|
"effect": {
|
||||||
"type": "shadow",
|
"type": "shadow",
|
||||||
"shadowType": "outer",
|
"shadowType": "outer",
|
||||||
"color": "#B4530952",
|
"color": "#00000026",
|
||||||
"offset": {
|
"offset": {
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 10
|
"y": 6
|
||||||
},
|
},
|
||||||
"blur": 28
|
"blur": 20
|
||||||
},
|
},
|
||||||
"gap": 10,
|
"gap": 10,
|
||||||
"padding": [
|
"padding": [
|
||||||
@ -240,23 +218,8 @@
|
|||||||
"name": "plusCirc",
|
"name": "plusCirc",
|
||||||
"width": 24,
|
"width": 24,
|
||||||
"height": 24,
|
"height": 24,
|
||||||
"fill": "#FFFFFF",
|
"fill": "#FDE047",
|
||||||
"cornerRadius": 20,
|
"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",
|
"justifyContent": "center",
|
||||||
"alignItems": "center",
|
"alignItems": "center",
|
||||||
"children": [
|
"children": [
|
||||||
@ -268,7 +231,7 @@
|
|||||||
"height": 12,
|
"height": 12,
|
||||||
"iconFontName": "plus",
|
"iconFontName": "plus",
|
||||||
"iconFontFamily": "lucide",
|
"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);
|
static const Color gemYellow = Color(0xFFFFD60A);
|
||||||
|
|
||||||
/// 旧版 Create Now 磨砂底(当前 UI 已改用金渐变 [PencilCreateNowButton];保留供参考)。
|
/// 旧版 Create Now 磨砂底(当前 [PencilCreateNowButton] 与 pen `aHMps` 白半透明一致;保留供参考)。
|
||||||
static const Color createPillFill = Color(0x4DFFFFFF);
|
static const Color createPillFill = Color(0x4DFFFFFF);
|
||||||
static const Color createPlusDisc = Color(0xFFFFD60A);
|
static const Color createPlusDisc = Color(0xFFFFD60A);
|
||||||
|
|
||||||
@ -65,6 +65,77 @@ abstract final class PencilTheme {
|
|||||||
static const Color genSlotBorder = Color(0xFFF5D08A);
|
static const Color genSlotBorder = Color(0xFFF5D08A);
|
||||||
static const Color genNavBackStroke = Color(0xFFE7E5E4);
|
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)
|
/// Credit Record 流水行卡片(`funymee_home.pen` listCr / ez9wP)
|
||||||
static const LinearGradient creditRecordRowGradient = LinearGradient(
|
static const LinearGradient creditRecordRowGradient = LinearGradient(
|
||||||
begin: Alignment.centerLeft,
|
begin: Alignment.centerLeft,
|
||||||
|
|||||||
@ -13,7 +13,9 @@ import '../../core/auth/auth_service.dart';
|
|||||||
import '../../core/open_purchase_store.dart';
|
import '../../core/open_purchase_store.dart';
|
||||||
import '../../core/user/user_state.dart';
|
import '../../core/user/user_state.dart';
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
|
import '../../widgets/image_upload_feedback_snackbars.dart';
|
||||||
import '../../widgets/pencil_chrome.dart';
|
import '../../widgets/pencil_chrome.dart';
|
||||||
|
import '../../widgets/pencil_yellow_white_background.dart';
|
||||||
import '../../widgets/resilient_network_video.dart';
|
import '../../widgets/resilient_network_video.dart';
|
||||||
import 'generate_result_screen.dart';
|
import 'generate_result_screen.dart';
|
||||||
|
|
||||||
@ -35,6 +37,9 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
File? _picked;
|
File? _picked;
|
||||||
File? _picked2;
|
File? _picked2;
|
||||||
|
|
||||||
|
/// 正在从相册/相机加载并校验的槽位(`0` / `1`);`null` 表示未在选图。
|
||||||
|
int? _pickLoadingSlot;
|
||||||
|
|
||||||
/// 对应 create-task 逻辑字段 `size`(换皮 `seminar`),如 480p / 720p。
|
/// 对应 create-task 逻辑字段 `size`(换皮 `seminar`),如 480p / 720p。
|
||||||
String _outputSize = '720p';
|
String _outputSize = '720p';
|
||||||
|
|
||||||
@ -49,6 +54,9 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
bool _pollNavigated = false;
|
bool _pollNavigated = false;
|
||||||
String? _pollTaskId;
|
String? _pollTaskId;
|
||||||
|
|
||||||
|
/// 来自本地缓存的 [UploadPresignedUrlResponse.expectedSize](预签名成功后写入);无缓存时为 [ImageUploadExpectedSizeCache.fallbackMaxBytes]。
|
||||||
|
int _maxUploadBytes = ImageUploadExpectedSizeCache.fallbackMaxBytes;
|
||||||
|
|
||||||
static const double _slotW = 112;
|
static const double _slotW = 112;
|
||||||
static const double _slotH = 108;
|
static const double _slotH = 108;
|
||||||
static const double _previewH = 359;
|
static const double _previewH = 359;
|
||||||
@ -127,6 +135,51 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_syncOutputSizeForTemplate();
|
_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
|
@override
|
||||||
@ -139,21 +192,38 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickSlot(int slot) async {
|
Future<void> _pickSlot(int slot) async {
|
||||||
if (_generating) return;
|
if (_generating || _pickLoadingSlot != null) return;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final source = await _showPickImageSourceSheet(context);
|
final source = await _showPickImageSourceSheet(context);
|
||||||
if (source == null || !mounted) return;
|
if (source == null || !mounted) return;
|
||||||
final x = await AuthService.runWithNativeMediaPicker(
|
setState(() => _pickLoadingSlot = slot);
|
||||||
() => _picker.pickImage(source: source, imageQuality: 92),
|
try {
|
||||||
);
|
final x = await AuthService.runWithNativeMediaPicker(
|
||||||
if (x == null || !mounted) return;
|
() => _picker.pickImage(source: source, imageQuality: 92),
|
||||||
setState(() {
|
);
|
||||||
if (slot == 0) {
|
if (x == null || !mounted) return;
|
||||||
_picked = File(x.path);
|
final f = File(x.path);
|
||||||
} else {
|
final len = await f.length();
|
||||||
_picked2 = File(x.path);
|
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) {
|
Future<ImageSource?> _showPickImageSourceSheet(BuildContext context) {
|
||||||
@ -459,11 +529,7 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final credits = UserState.credits.value;
|
return PencilYellowWhitePageBackground(
|
||||||
return Container(
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: PencilTheme.yellowWhitePageGradient,
|
|
||||||
),
|
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
@ -844,32 +910,68 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
side: const BorderSide(color: PencilTheme.genSlotBorder, width: 1.5),
|
side: const BorderSide(color: PencilTheme.genSlotBorder, width: 1.5),
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => _pickSlot(slotIndex),
|
onTap: (_generating || _pickLoadingSlot != null)
|
||||||
|
? null
|
||||||
|
: () => _pickSlot(slotIndex),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: file == null
|
child: Stack(
|
||||||
? Column(
|
fit: StackFit.expand,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
children: [
|
||||||
children: [
|
file == null
|
||||||
Icon(
|
? Column(
|
||||||
Icons.add_photo_alternate_outlined,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
color: PencilTheme.profileAvatarIcon,
|
children: [
|
||||||
size: 26,
|
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),
|
if (_pickLoadingSlot == slotIndex)
|
||||||
Text(
|
ColoredBox(
|
||||||
label,
|
color: Colors.white.withValues(alpha: 0.88),
|
||||||
style: GoogleFonts.inter(
|
child: Center(
|
||||||
fontSize: 11,
|
child: Column(
|
||||||
fontWeight: FontWeight.w700,
|
mainAxisSize: MainAxisSize.min,
|
||||||
color: PencilTheme.stone600,
|
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),
|
const SizedBox(height: 8),
|
||||||
Text(
|
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(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
|
|||||||
@ -18,16 +18,25 @@ class CreditRecordTab extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CreditRecordTabState extends State<CreditRecordTab> {
|
class _CreditRecordTabState extends State<CreditRecordTab> {
|
||||||
|
static const int _pageSize = 30;
|
||||||
|
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
bool _hasMore = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<CreditRecordItem> _records = [];
|
List<CreditRecordItem> _records = [];
|
||||||
|
int _lastLoadedPage = 0;
|
||||||
|
/// 下拉刷新时递增,用于丢弃刷新前未完成的「加载更多」响应。
|
||||||
|
int _listGeneration = 0;
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
VoidCallback? _cancelLoginWait;
|
VoidCallback? _cancelLoginWait;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_scrollController.addListener(_onScrollNearEnd);
|
||||||
_cancelLoginWait = AuthService.whenLoginSucceeded(
|
_cancelLoginWait = AuthService.whenLoginSucceeded(
|
||||||
onReady: _load,
|
onReady: _reload,
|
||||||
onFailed: () {
|
onFailed: () {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@ -40,17 +49,52 @@ class _CreditRecordTabState extends State<CreditRecordTab> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_scrollController.removeListener(_onScrollNearEnd);
|
||||||
|
_scrollController.dispose();
|
||||||
_cancelLoginWait?.call();
|
_cancelLoginWait?.call();
|
||||||
super.dispose();
|
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(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
|
_loadingMore = false;
|
||||||
_error = null;
|
_error = null;
|
||||||
|
_hasMore = true;
|
||||||
|
_records = [];
|
||||||
|
_lastLoadedPage = 0;
|
||||||
});
|
});
|
||||||
final res = await UserApi.getCreditsPage(page: '1', size: '30', type: '1');
|
final res = await UserApi.getCreditsPage(page: '1', size: '$_pageSize');
|
||||||
if (!mounted) return;
|
if (!mounted || gen != _listGeneration) return;
|
||||||
if (!res.isSuccess || res.data == null) {
|
if (!res.isSuccess || res.data == null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = false;
|
_loading = false;
|
||||||
@ -58,9 +102,40 @@ class _CreditRecordTabState extends State<CreditRecordTab> {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
final incoming = res.data!.records ?? [];
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = false;
|
_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);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final bottomPad = EdgeInsets.only(bottom: widget.extraBottomInset);
|
final bottomPad = EdgeInsets.only(bottom: widget.extraBottomInset);
|
||||||
@ -91,7 +185,7 @@ class _CreditRecordTabState extends State<CreditRecordTab> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(_error!),
|
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) {
|
if (_records.isEmpty) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: bottomPad,
|
padding: bottomPad,
|
||||||
child: Center(
|
child: RefreshIndicator(
|
||||||
child: Text(
|
color: PencilTheme.underlineGold,
|
||||||
'No records.',
|
onRefresh: _reload,
|
||||||
style: GoogleFonts.inter(color: PencilTheme.inkSoft),
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
height: 320,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'No records.',
|
||||||
|
style: GoogleFonts.inter(color: PencilTheme.inkSoft),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
color: PencilTheme.underlineGold,
|
color: PencilTheme.underlineGold,
|
||||||
onRefresh: _load,
|
onRefresh: _reload,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
|
controller: _scrollController,
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
16,
|
16,
|
||||||
8,
|
8,
|
||||||
16,
|
16,
|
||||||
28 + widget.extraBottomInset,
|
28 + widget.extraBottomInset,
|
||||||
),
|
),
|
||||||
itemCount: _records.length,
|
itemCount: _records.length + (_loadingMore ? 1 : 0),
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||||
itemBuilder: (_, i) {
|
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 r = _records[i];
|
||||||
final c = r.credits ?? 0;
|
final (amountLabel, amountColor) = _amountStyle(r);
|
||||||
return _CreditRecordRowCard(
|
return _CreditRecordRowCard(
|
||||||
amountLabel: '${c > 0 ? '+' : ''}$c',
|
amountLabel: amountLabel,
|
||||||
|
amountColor: amountColor,
|
||||||
dateLabel: _formatDate(r.createTime),
|
dateLabel: _formatDate(r.createTime),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -137,10 +261,12 @@ class _CreditRecordTabState extends State<CreditRecordTab> {
|
|||||||
class _CreditRecordRowCard extends StatelessWidget {
|
class _CreditRecordRowCard extends StatelessWidget {
|
||||||
const _CreditRecordRowCard({
|
const _CreditRecordRowCard({
|
||||||
required this.amountLabel,
|
required this.amountLabel,
|
||||||
|
required this.amountColor,
|
||||||
required this.dateLabel,
|
required this.dateLabel,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String amountLabel;
|
final String amountLabel;
|
||||||
|
final Color amountColor;
|
||||||
final String dateLabel;
|
final String dateLabel;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -185,7 +311,7 @@ class _CreditRecordRowCard extends StatelessWidget {
|
|||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
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:client_proxy_framework/client_proxy_framework.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.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 '../../core/user/user_state.dart';
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
import '../../widgets/pencil_chrome.dart';
|
import '../../widgets/pencil_chrome.dart';
|
||||||
|
import '../../widgets/pencil_yellow_white_background.dart';
|
||||||
import '../generate/generate_result_screen.dart';
|
import '../generate/generate_result_screen.dart';
|
||||||
|
import '../profile/delete_account_flow.dart';
|
||||||
import 'credit_record_tab.dart';
|
import 'credit_record_tab.dart';
|
||||||
import 'history_media_save.dart';
|
import 'history_media_save.dart';
|
||||||
import 'history_task_progress_screen.dart';
|
import 'history_task_progress_screen.dart';
|
||||||
@ -39,6 +43,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
List<MyTaskItem> _items = [];
|
List<MyTaskItem> _items = [];
|
||||||
Map<String, String> _localCovers = {};
|
Map<String, String> _localCovers = {};
|
||||||
final Set<String> _downloadingTaskIds = {};
|
final Set<String> _downloadingTaskIds = {};
|
||||||
|
final Set<String> _deletingTaskIds = {};
|
||||||
VoidCallback? _cancelLoginWait;
|
VoidCallback? _cancelLoginWait;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -79,6 +84,69 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
_load();
|
_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 {
|
Future<void> _load() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_loading = true;
|
_loading = true;
|
||||||
@ -108,10 +176,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return PencilYellowWhitePageBackground(
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: PencilTheme.yellowWhitePageGradient,
|
|
||||||
),
|
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
@ -123,8 +188,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 58,
|
height: 58,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding:
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
PencilRoundCloseButton(
|
PencilRoundCloseButton(
|
||||||
@ -216,10 +280,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(_error ?? 'Sign in failed', textAlign: TextAlign.center),
|
||||||
_error ?? 'Sign in failed',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -258,8 +319,10 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.only(bottom: shellBottom),
|
padding: EdgeInsets.only(bottom: shellBottom),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text('No tasks yet.',
|
child: Text(
|
||||||
style: GoogleFonts.inter(color: PencilTheme.inkSoft)),
|
'No tasks yet.',
|
||||||
|
style: GoogleFonts.inter(color: PencilTheme.inkSoft),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@ -273,87 +336,88 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
crossAxisSpacing: 14,
|
crossAxisSpacing: 14,
|
||||||
childAspectRatio: 171 / 182,
|
childAspectRatio: 171 / 182,
|
||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate((context, i) {
|
||||||
(context, i) {
|
final t = _items[i];
|
||||||
final t = _items[i];
|
final id = t.taskId ?? '';
|
||||||
final id = t.taskId ?? '';
|
final raw = myTaskListingRaw(t);
|
||||||
final raw = myTaskListingRaw(t);
|
final display = listingDisplayFromApi(raw);
|
||||||
final display = listingDisplayFromApi(raw);
|
final canDl = myTaskCanShowDownload(t);
|
||||||
final canDl = myTaskCanShowDownload(t);
|
final statusLabel = myTaskStatusLabel(t);
|
||||||
final statusLabel = myTaskStatusLabel(t);
|
return HistoryGridCard(
|
||||||
return HistoryGridCard(
|
item: t,
|
||||||
item: t,
|
localCoverPath: id.isEmpty ? null : _localCovers[id],
|
||||||
localCoverPath:
|
showDownload: canDl,
|
||||||
id.isEmpty ? null : _localCovers[id],
|
statusLabel: statusLabel,
|
||||||
showDownload: canDl,
|
isDownloading:
|
||||||
statusLabel: statusLabel,
|
id.isNotEmpty && _downloadingTaskIds.contains(id),
|
||||||
isDownloading:
|
isDeleting:
|
||||||
id.isNotEmpty && _downloadingTaskIds.contains(id),
|
id.isNotEmpty && _deletingTaskIds.contains(id),
|
||||||
onTap: () {
|
onDelete: id.isEmpty
|
||||||
if (id.isEmpty) return;
|
? null
|
||||||
// 有结果 URL 优先进预览(与列表「完成」态以地址为准一致)
|
: () {
|
||||||
if (myTaskHasRemoteResultUrl(t)) {
|
unawaited(_confirmAndDeleteTask(t));
|
||||||
Navigator.of(context).push(
|
},
|
||||||
MaterialPageRoute<void>(
|
onTap: () {
|
||||||
builder: (_) => GenerateResultScreen(
|
if (id.isEmpty) return;
|
||||||
taskId: id,
|
// 有结果 URL 优先进预览(与列表「完成」态以地址为准一致)
|
||||||
resultUrl: t.resultUrl?.trim() ?? '',
|
if (myTaskHasRemoteResultUrl(t)) {
|
||||||
),
|
Navigator.of(context).push(
|
||||||
),
|
MaterialPageRoute<void>(
|
||||||
);
|
builder: (_) => GenerateResultScreen(
|
||||||
return;
|
taskId: id,
|
||||||
}
|
resultUrl: t.resultUrl?.trim() ?? '',
|
||||||
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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
return;
|
||||||
onDownload: canDl
|
}
|
||||||
? () async {
|
if (myTaskIsInProgress(t)) {
|
||||||
final u = t.resultUrl?.trim() ?? '';
|
Navigator.of(context).push(
|
||||||
if (u.isEmpty || id.isEmpty) return;
|
MaterialPageRoute<void>(
|
||||||
setState(() => _downloadingTaskIds.add(id));
|
builder: (_) =>
|
||||||
try {
|
HistoryTaskProgressScreen(taskId: id),
|
||||||
await saveHistoryMediaToGallery(
|
),
|
||||||
context: context,
|
);
|
||||||
taskId: id,
|
return;
|
||||||
resultUrl: u,
|
}
|
||||||
);
|
if (galleryListingIsFinishedSuccess(raw, display)) {
|
||||||
} finally {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
if (mounted) {
|
const SnackBar(
|
||||||
setState(
|
content: Text(
|
||||||
() => _downloadingTaskIds.remove(id),
|
'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,
|
}
|
||||||
);
|
: null,
|
||||||
},
|
);
|
||||||
childCount: _items.length,
|
}, childCount: _items.length),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import '../../core/app_env.dart';
|
|||||||
import '../../core/user/user_state.dart';
|
import '../../core/user/user_state.dart';
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
import '../../widgets/pencil_chrome.dart';
|
import '../../widgets/pencil_chrome.dart';
|
||||||
|
import '../../widgets/pencil_yellow_white_background.dart';
|
||||||
import '../generate/generate_result_screen.dart';
|
import '../generate/generate_result_screen.dart';
|
||||||
|
|
||||||
/// 从 My History 点「生成中」任务进入:轮询 [ImageApi.getProgress],完成后进入 [GenerateResultScreen]。
|
/// 从 My History 点「生成中」任务进入:轮询 [ImageApi.getProgress],完成后进入 [GenerateResultScreen]。
|
||||||
@ -98,10 +99,7 @@ class _HistoryTaskProgressScreenState extends State<HistoryTaskProgressScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return PencilYellowWhitePageBackground(
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: PencilTheme.yellowWhitePageGradient,
|
|
||||||
),
|
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
|||||||
@ -20,15 +20,18 @@ class HistoryGridCard extends StatelessWidget {
|
|||||||
this.localCoverPath,
|
this.localCoverPath,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.onDownload,
|
this.onDownload,
|
||||||
|
this.onDelete,
|
||||||
this.showDownload = true,
|
this.showDownload = true,
|
||||||
this.statusLabel = '',
|
this.statusLabel = '',
|
||||||
this.isDownloading = false,
|
this.isDownloading = false,
|
||||||
|
this.isDeleting = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final MyTaskItem item;
|
final MyTaskItem item;
|
||||||
final String? localCoverPath;
|
final String? localCoverPath;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onDownload;
|
final VoidCallback? onDownload;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
|
||||||
/// 仅完成态且可下载时显示 Download;否则展示 [statusLabel](与 app_client 图库一致)。
|
/// 仅完成态且可下载时显示 Download;否则展示 [statusLabel](与 app_client 图库一致)。
|
||||||
final bool showDownload;
|
final bool showDownload;
|
||||||
@ -36,6 +39,7 @@ class HistoryGridCard extends StatelessWidget {
|
|||||||
|
|
||||||
/// 保存到相册进行中:pill 显示加载,直至 [onDownload] 结束。
|
/// 保存到相册进行中:pill 显示加载,直至 [onDownload] 结束。
|
||||||
final bool isDownloading;
|
final bool isDownloading;
|
||||||
|
final bool isDeleting;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -93,45 +97,85 @@ class HistoryGridCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 10,
|
left: 8,
|
||||||
top: 10,
|
top: 6,
|
||||||
right: 10,
|
right: 6,
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Expanded(
|
||||||
dateLabel,
|
child: Column(
|
||||||
maxLines: 1,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
overflow: TextOverflow.ellipsis,
|
children: [
|
||||||
style: GoogleFonts.inter(
|
Text(
|
||||||
fontSize: 9,
|
dateLabel,
|
||||||
fontWeight: FontWeight.w500,
|
maxLines: 1,
|
||||||
color: Colors.white.withValues(alpha: 0.95),
|
overflow: TextOverflow.ellipsis,
|
||||||
shadows: const [
|
style: GoogleFonts.inter(
|
||||||
Shadow(
|
fontSize: 9,
|
||||||
blurRadius: 4,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0x40000000),
|
color: Colors.white.withValues(alpha: 0.95),
|
||||||
offset: Offset(0, 1),
|
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),
|
if (onDelete != null) ...[
|
||||||
Text(
|
const SizedBox(width: 4),
|
||||||
remainder,
|
Material(
|
||||||
style: GoogleFonts.inter(
|
color: Colors.black.withValues(alpha: 0.4),
|
||||||
fontSize: 9,
|
shape: const CircleBorder(),
|
||||||
fontWeight: FontWeight.w600,
|
child: IconButton(
|
||||||
color: PencilTheme.underlineGold,
|
onPressed: isDeleting ? null : onDelete,
|
||||||
shadows: const [
|
padding: EdgeInsets.zero,
|
||||||
Shadow(
|
constraints: const BoxConstraints(
|
||||||
blurRadius: 4,
|
minWidth: 30,
|
||||||
color: Color(0x40000000),
|
minHeight: 30,
|
||||||
offset: Offset(0, 1),
|
|
||||||
),
|
),
|
||||||
],
|
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 '../../core/user/user_state.dart';
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
import '../../widgets/pencil_chrome.dart';
|
import '../../widgets/pencil_chrome.dart';
|
||||||
|
import '../../widgets/pencil_yellow_white_background.dart';
|
||||||
import '../web/app_web_view_screen.dart';
|
import '../web/app_web_view_screen.dart';
|
||||||
import 'delete_account_flow.dart';
|
import 'delete_account_flow.dart';
|
||||||
|
|
||||||
@ -37,10 +38,7 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return PencilYellowWhitePageBackground(
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: PencilTheme.yellowWhitePageGradient,
|
|
||||||
),
|
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
|
|||||||
@ -10,7 +10,9 @@ import '../../core/payment/google_play_order_recovery.dart';
|
|||||||
import '../../core/user/user_state.dart';
|
import '../../core/user/user_state.dart';
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
import '../../widgets/pencil_chrome.dart';
|
import '../../widgets/pencil_chrome.dart';
|
||||||
|
import '../../widgets/pencil_yellow_white_background.dart';
|
||||||
import '../web/app_web_view_screen.dart';
|
import '../web/app_web_view_screen.dart';
|
||||||
|
import 'third_party_payment_sheet.dart';
|
||||||
|
|
||||||
/// `ETbdo` Purchase Point:黄渐变、Bonheur 标题、积分区 + [xiabiao]、双列档位 + [credit_tag] 角标。
|
/// `ETbdo` Purchase Point:黄渐变、Bonheur 标题、积分区 + [xiabiao]、双列档位 + [credit_tag] 角标。
|
||||||
/// 商品来自 [PaymentFlowCatalog.loadStoreActivities]。
|
/// 商品来自 [PaymentFlowCatalog.loadStoreActivities]。
|
||||||
@ -101,107 +103,14 @@ class _PurchaseScreenState extends State<PurchaseScreen>
|
|||||||
|
|
||||||
Future<PaymentMethodItem?> _pickPaymentMethod(
|
Future<PaymentMethodItem?> _pickPaymentMethod(
|
||||||
List<PaymentMethodItem> methods,
|
List<PaymentMethodItem> methods,
|
||||||
|
PaymentProductItem product,
|
||||||
) {
|
) {
|
||||||
return showModalBottomSheet<PaymentMethodItem>(
|
return showThirdPartyPaymentMethodSheet(
|
||||||
context: context,
|
context,
|
||||||
backgroundColor: Colors.white,
|
methods: methods,
|
||||||
shape: const RoundedRectangleBorder(
|
product: product,
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
summaryTierCredits: product.credits,
|
||||||
),
|
summaryTierBonus: product.bonus,
|
||||||
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]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,7 +170,7 @@ class _PurchaseScreenState extends State<PurchaseScreen>
|
|||||||
_paying = false;
|
_paying = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
final picked = await _pickPaymentMethod(methods);
|
final picked = await _pickPaymentMethod(methods, item);
|
||||||
if (!mounted || picked == null) {
|
if (!mounted || picked == null) {
|
||||||
_resetPayingState();
|
_resetPayingState();
|
||||||
return;
|
return;
|
||||||
@ -456,10 +365,7 @@ class _PurchaseScreenState extends State<PurchaseScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return PencilYellowWhitePageBackground(
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: PencilTheme.yellowWhitePageGradient,
|
|
||||||
),
|
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
@ -781,20 +687,10 @@ class _ProductCard extends StatelessWidget {
|
|||||||
|
|
||||||
static final _money = RegExp(r'[\d.]+');
|
static final _money = RegExp(r'[\d.]+');
|
||||||
|
|
||||||
/// [PaymentProductItem.bonus] + [PaymentProductItem.bonusCredits](换皮线网 `contrast` + `saturation`)。
|
/// 赠送展示:仅 [PaymentProductItem.bonus](线网常为 `contrast` 映射),`+N bonus`。
|
||||||
static String? _bonusDisplayLine({
|
static String? _bonusDisplayLine(int total) {
|
||||||
required int base,
|
|
||||||
required int gift,
|
|
||||||
required int total,
|
|
||||||
}) {
|
|
||||||
if (total <= 0) return null;
|
if (total <= 0) return null;
|
||||||
if (gift > 0 && base > 0) {
|
return '+$total bonus';
|
||||||
return '+$total bonus (incl. $gift gift)';
|
|
||||||
}
|
|
||||||
if (gift > 0) {
|
|
||||||
return '+$gift gift credits';
|
|
||||||
}
|
|
||||||
return '+$base bonus';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static int? _discountPercent(String? actual, String? origin) {
|
static int? _discountPercent(String? actual, String? origin) {
|
||||||
@ -824,14 +720,7 @@ class _ProductCard extends StatelessWidget {
|
|||||||
: 'Credits:—';
|
: 'Credits:—';
|
||||||
final actual = item.actualAmount ?? '—';
|
final actual = item.actualAmount ?? '—';
|
||||||
final origin = item.originAmount;
|
final origin = item.originAmount;
|
||||||
final bonusBase = item.bonus ?? 0;
|
final bonusLine = _bonusDisplayLine(item.bonus ?? 0);
|
||||||
final bonusGift = item.bonusCredits ?? 0;
|
|
||||||
final bonusTotal = bonusBase + bonusGift;
|
|
||||||
final bonusLine = _bonusDisplayLine(
|
|
||||||
base: bonusBase,
|
|
||||||
gift: bonusGift,
|
|
||||||
total: bonusTotal,
|
|
||||||
);
|
|
||||||
final pct = _discountPercent(item.actualAmount, item.originAmount);
|
final pct = _discountPercent(item.actualAmount, item.originAmount);
|
||||||
|
|
||||||
return Material(
|
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!;
|
final p = presignedRes.data!;
|
||||||
|
await ImageUploadExpectedSizeCache.writeFeedbackExpectedSize(p.expectedSize);
|
||||||
final uploadUrl = p.uploadUrl;
|
final uploadUrl = p.uploadUrl;
|
||||||
final filePath = p.filePath;
|
final filePath = p.filePath;
|
||||||
if (uploadUrl == null ||
|
if (uploadUrl == null ||
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:client_proxy_framework/client_proxy_framework.dart';
|
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 '../../core/auth/auth_service.dart';
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
|
import '../../widgets/image_upload_feedback_snackbars.dart';
|
||||||
import '../../widgets/pencil_chrome.dart';
|
import '../../widgets/pencil_chrome.dart';
|
||||||
|
import '../../widgets/pencil_yellow_white_background.dart';
|
||||||
import 'report_feedback_upload.dart';
|
import 'report_feedback_upload.dart';
|
||||||
|
|
||||||
/// Report / feedback screen.
|
/// Report / feedback screen.
|
||||||
@ -28,10 +31,51 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||||||
final _picker = ImagePicker();
|
final _picker = ImagePicker();
|
||||||
File? _imageFile;
|
File? _imageFile;
|
||||||
bool _submitting = false;
|
bool _submitting = false;
|
||||||
|
bool _pickImageLoading = false;
|
||||||
|
|
||||||
|
/// 来自本地缓存的 [FeedbackUploadPresignedUrlResponse.expectedSize];无缓存时为 [ImageUploadExpectedSizeCache.fallbackMaxBytes]。
|
||||||
|
int _maxFeedbackImageBytes = ImageUploadExpectedSizeCache.fallbackMaxBytes;
|
||||||
|
|
||||||
/// Logical `contentType` for [FeedbackApi.submit] (maps via `fieldMapping` when sent).
|
/// Logical `contentType` for [FeedbackApi.submit] (maps via `fieldMapping` when sent).
|
||||||
static const _feedbackContentType = 'report';
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
@ -39,15 +83,33 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickImage() async {
|
Future<void> _pickImage() async {
|
||||||
if (_submitting) return;
|
if (_submitting || _pickImageLoading) return;
|
||||||
final x = await AuthService.runWithNativeMediaPicker(
|
setState(() => _pickImageLoading = true);
|
||||||
() => _picker.pickImage(
|
try {
|
||||||
source: ImageSource.gallery,
|
final x = await AuthService.runWithNativeMediaPicker(
|
||||||
imageQuality: 85,
|
() => _picker.pickImage(
|
||||||
),
|
source: ImageSource.gallery,
|
||||||
);
|
imageQuality: 85,
|
||||||
if (x == null || !mounted) return;
|
),
|
||||||
setState(() => _imageFile = File(x.path));
|
);
|
||||||
|
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() {
|
void _clearImage() {
|
||||||
@ -67,6 +129,14 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||||||
try {
|
try {
|
||||||
final urls = <String>[];
|
final urls = <String>[];
|
||||||
if (_imageFile != null) {
|
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!);
|
final path = await uploadFeedbackAttachment(_imageFile!);
|
||||||
urls.add(path);
|
urls.add(path);
|
||||||
}
|
}
|
||||||
@ -106,10 +176,8 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
final feedbackMaxLabel = _formatMaxUploadLabel(_maxFeedbackImageBytes);
|
||||||
decoration: const BoxDecoration(
|
return PencilYellowWhitePageBackground(
|
||||||
gradient: PencilTheme.yellowWhitePageGradient,
|
|
||||||
),
|
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
@ -239,6 +307,16 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||||||
color: PencilTheme.stone900,
|
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),
|
const SizedBox(height: 10),
|
||||||
Row(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -246,7 +324,9 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||||||
Material(
|
Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: _submitting ? null : _pickImage,
|
onTap: (_submitting || _pickImageLoading)
|
||||||
|
? null
|
||||||
|
: _pickImage,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: Ink(
|
child: Ink(
|
||||||
width: 112,
|
width: 112,
|
||||||
@ -258,23 +338,60 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||||||
color: PencilTheme.genNavBackStroke,
|
color: PencilTheme.genNavBackStroke,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: _imageFile == null
|
child: Stack(
|
||||||
? Icon(
|
fit: StackFit.expand,
|
||||||
Icons.add_photo_alternate_outlined,
|
children: [
|
||||||
size: 36,
|
_imageFile == null
|
||||||
color: PencilTheme.stone600.withValues(
|
? Center(
|
||||||
alpha: 0.7,
|
child: Icon(
|
||||||
),
|
Icons.add_photo_alternate_outlined,
|
||||||
)
|
size: 36,
|
||||||
: ClipRRect(
|
color: PencilTheme.stone600
|
||||||
borderRadius: BorderRadius.circular(12),
|
.withValues(alpha: 0.7),
|
||||||
child: Image.file(
|
),
|
||||||
_imageFile!,
|
)
|
||||||
fit: BoxFit.cover,
|
: ClipRRect(
|
||||||
width: double.infinity,
|
borderRadius:
|
||||||
height: double.infinity,
|
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 {
|
class PencilCreateNowButton extends StatelessWidget {
|
||||||
const PencilCreateNowButton({super.key, required this.onPressed});
|
const PencilCreateNowButton({super.key, required this.onPressed});
|
||||||
|
|
||||||
@ -110,97 +110,79 @@ class PencilCreateNowButton extends StatelessWidget {
|
|||||||
static const double _w = 186;
|
static const double _w = 186;
|
||||||
static const double _h = 42;
|
static const double _h = 42;
|
||||||
|
|
||||||
/// `aHMps` opacity
|
/// `aHMps` gap;横向 padding 22;竖直 `(42-24)/2` 以垂直居中 24×24 左标。
|
||||||
static const double _opacity = 0.88;
|
|
||||||
|
|
||||||
/// `aHMps` gap;横向 padding 取 pen `padding` 中 22;竖直取 `(42-24)/2` 以垂直居中 24×24 左标。
|
|
||||||
static const double _gap = 10;
|
static const double _gap = 10;
|
||||||
static const EdgeInsets _padding =
|
static const EdgeInsets _padding =
|
||||||
EdgeInsets.symmetric(horizontal: 22, vertical: 9);
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Opacity(
|
// 勿用 [Ink] 包一层 [BoxDecoration] 圆角底:父级 Material 的墨水层是矩形,半透明会在圆角外露出成「方块」背板。
|
||||||
opacity: _opacity,
|
// 这里用 [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(
|
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(
|
child: InkWell(
|
||||||
onTap: onPressed,
|
onTap: onPressed,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
child: Ink(
|
child: Padding(
|
||||||
decoration: BoxDecoration(
|
padding: _padding,
|
||||||
borderRadius: BorderRadius.circular(999),
|
child: Row(
|
||||||
gradient: const LinearGradient(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
begin: Alignment.topCenter,
|
mainAxisSize: MainAxisSize.min,
|
||||||
end: Alignment.bottomCenter,
|
children: [
|
||||||
colors: [
|
DecoratedBox(
|
||||||
Color(0xFFFFFDE7),
|
decoration: BoxDecoration(
|
||||||
Color(0xFFFDE047),
|
color: _plusDisc,
|
||||||
Color(0xFFF59E0B),
|
borderRadius: BorderRadius.circular(20),
|
||||||
],
|
),
|
||||||
stops: [0.0, 0.42, 1.0],
|
child: const SizedBox(
|
||||||
),
|
width: 24,
|
||||||
border: Border.all(
|
height: 24,
|
||||||
color: Color(0xD9FFFFFF),
|
child: Icon(
|
||||||
width: 2,
|
Icons.add_rounded,
|
||||||
),
|
size: 12,
|
||||||
boxShadow: const [
|
color: Colors.black,
|
||||||
BoxShadow(
|
),
|
||||||
color: Color(0x52B45309),
|
),
|
||||||
offset: Offset(0, 10),
|
),
|
||||||
blurRadius: 28,
|
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."
|
description: "FunyMee AI Application."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
|
|
||||||
version: 1.0.15+15
|
version: 1.0.16+16
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.1
|
sdk: ^3.11.1
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user