优化:三方支付优化,积分记录优化

This commit is contained in:
ivan 2026-04-22 23:04:55 +08:00
parent 07a047f031
commit 53497edc62
19 changed files with 2193 additions and 497 deletions

View 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 repos Pencil files** live under `desgin/` (e.g. `funymee_home.pen`). Editing them should go through **Cursors 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 users setup.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -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"
}
]
}
]
}
]
} }
] ]
} }

View File

@ -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,

View File

@ -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,

View File

@ -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,
), ),
), ),
], ],

View File

@ -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),
),
), ),
), ),
], ],

View File

@ -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(

View File

@ -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,
),
),
), ),
), ],
], ],
), ),
), ),

View File

@ -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),
),
),
],
),
],
),
),
),
);
}
}

View File

@ -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(

View File

@ -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 PointBonheur + [xiabiao] + [credit_tag] /// `ETbdo` Purchase PointBonheur + [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),
);
}
}

View 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);
}
}

View File

@ -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 ||

View File

@ -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,
),
),
],
),
), ),
), ),
],
),
), ),
), ),
), ),

View 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,
),
),
],
),
),
],
),
),
);
}

View File

@ -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,
),
),
],
),
),
),
), ),
), ),
), ),

View 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,
],
);
}
}

View File

@ -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