315 lines
11 KiB
Dart
315 lines
11 KiB
Dart
import 'package:cached_network_image/cached_network_image.dart';
|
||
import 'package:client_proxy_framework/client_proxy_framework.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:google_fonts/google_fonts.dart';
|
||
import 'package:package_info_plus/package_info_plus.dart';
|
||
|
||
import '../../core/app_env.dart';
|
||
import '../../core/open_purchase_store.dart';
|
||
import '../../core/ext_config_document_urls.dart';
|
||
import '../../core/user/user_state.dart';
|
||
import '../../design/pencil_theme.dart';
|
||
import '../../widgets/pencil_chrome.dart';
|
||
import '../web/app_web_view_screen.dart';
|
||
import 'delete_account_flow.dart';
|
||
|
||
/// `5J8Po` 个人中心。
|
||
class ProfileScreen extends StatefulWidget {
|
||
const ProfileScreen({super.key, this.isRootTab = false});
|
||
|
||
/// When true (e.g. bottom tab), hide close; when pushed, show close.
|
||
final bool isRootTab;
|
||
|
||
@override
|
||
State<ProfileScreen> createState() => _ProfileScreenState();
|
||
}
|
||
|
||
class _ProfileScreenState extends State<ProfileScreen> {
|
||
String _version = '…';
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
PackageInfo.fromPlatform().then((p) {
|
||
if (mounted) setState(() => _version = p.version);
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
decoration: const BoxDecoration(
|
||
gradient: PencilTheme.yellowWhitePageGradient,
|
||
),
|
||
child: Scaffold(
|
||
backgroundColor: Colors.transparent,
|
||
body: SafeArea(
|
||
bottom: false,
|
||
child: Column(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(2, 0, 14, 10),
|
||
child: SizedBox(
|
||
height: 56,
|
||
child: Align(
|
||
alignment: Alignment.centerRight,
|
||
child: PencilRoundCloseButton(
|
||
onPressed: () => Navigator.maybePop(context),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: ListView(
|
||
padding: EdgeInsets.only(
|
||
bottom:
|
||
28 +
|
||
(widget.isRootTab
|
||
? PencilTheme.mainTabBottomChromeReserve(context)
|
||
: 0),
|
||
),
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||
child: Column(
|
||
children: [
|
||
Container(
|
||
width: 100,
|
||
height: 100,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: Colors.white,
|
||
border: Border.all(
|
||
color: PencilTheme.profileAvatarRing,
|
||
width: 2,
|
||
),
|
||
),
|
||
child: ValueListenableBuilder<String?>(
|
||
valueListenable: UserState.avatar,
|
||
builder: (context, url, _) {
|
||
if (url != null && url.isNotEmpty) {
|
||
return ClipOval(
|
||
child: CachedNetworkImage(
|
||
imageUrl: url,
|
||
fit: BoxFit.cover,
|
||
width: 100,
|
||
height: 100,
|
||
memCacheWidth: 200,
|
||
fadeInDuration: Duration.zero,
|
||
fadeOutDuration: Duration.zero,
|
||
placeholder: (context, url) => Center(
|
||
child: SizedBox(
|
||
width: 28,
|
||
height: 28,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: PencilTheme
|
||
.profileAvatarIcon
|
||
.withValues(alpha: 0.5),
|
||
),
|
||
),
|
||
),
|
||
errorWidget: (context, url, error) =>
|
||
_avatarFallback(),
|
||
),
|
||
);
|
||
}
|
||
return _avatarFallback();
|
||
},
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
ValueListenableBuilder<String?>(
|
||
valueListenable: UserState.userId,
|
||
builder: (context, id, _) {
|
||
return Text(
|
||
'ID:${id ?? '—'}',
|
||
style: GoogleFonts.inter(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w700,
|
||
color: PencilTheme.stone900,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
const SizedBox(height: 4),
|
||
ValueListenableBuilder<int>(
|
||
valueListenable: UserState.credits,
|
||
builder: (context, c, _) {
|
||
return InkWell(
|
||
onTap: () => openPurchaseStore(context),
|
||
child: Text(
|
||
'Credits · ${_formatCredits(c)}',
|
||
style: GoogleFonts.inter(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
color: PencilTheme.profileCredits,
|
||
decoration: TextDecoration.underline,
|
||
decorationColor: PencilTheme.profileCredits
|
||
.withValues(alpha: 0.45),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 12),
|
||
child: _menuCard(context),
|
||
),
|
||
|
||
const SizedBox(height: 24),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _avatarFallback() {
|
||
return Icon(
|
||
Icons.person_rounded,
|
||
size: 44,
|
||
color: PencilTheme.profileAvatarIcon,
|
||
);
|
||
}
|
||
|
||
String _formatCredits(int c) {
|
||
final s = c.toString();
|
||
if (s.length <= 3) return s;
|
||
return '${s.substring(0, s.length - 3)} ${s.substring(s.length - 3)}';
|
||
}
|
||
|
||
void _openAppWebView(
|
||
BuildContext context, {
|
||
required String title,
|
||
required String? url,
|
||
}) {
|
||
if (url == null || url.trim().isEmpty) {
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(const SnackBar(content: Text('Link not configured')));
|
||
return;
|
||
}
|
||
Navigator.of(context).push<void>(
|
||
MaterialPageRoute<void>(
|
||
builder: (_) => AppWebViewScreen(title: title, initialUrl: url.trim()),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _menuCard(BuildContext context) {
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: PencilTheme.genHintBorder),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: const Color(0x30CA8A04),
|
||
blurRadius: 20,
|
||
offset: const Offset(0, 6),
|
||
),
|
||
],
|
||
),
|
||
child: Column(
|
||
children: [
|
||
_row(
|
||
'Terms of Service',
|
||
trailing: Icons.chevron_right_rounded,
|
||
onTap: () => _openAppWebView(
|
||
context,
|
||
title: 'Terms of Service',
|
||
url: ExtConfigDocumentUrls.agreementUrl,
|
||
),
|
||
),
|
||
_divider(),
|
||
_row(
|
||
'Privacy Policy',
|
||
trailing: Icons.chevron_right_rounded,
|
||
onTap: () => _openAppWebView(
|
||
context,
|
||
title: 'Privacy Policy',
|
||
url: ExtConfigDocumentUrls.privacyUrl,
|
||
),
|
||
),
|
||
_divider(),
|
||
_row('Version', value: 'v$_version'),
|
||
_divider(),
|
||
_row(
|
||
'Delete account',
|
||
danger: true,
|
||
trailing: Icons.chevron_right_rounded,
|
||
onTap: () => _delete(context),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _row(
|
||
String title, {
|
||
IconData? trailing,
|
||
String? value,
|
||
bool danger = false,
|
||
VoidCallback? onTap,
|
||
}) {
|
||
return ListTile(
|
||
onTap: onTap,
|
||
title: Text(
|
||
title,
|
||
style: GoogleFonts.inter(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
color: danger ? const Color(0xFFDC2626) : PencilTheme.stone700,
|
||
),
|
||
),
|
||
trailing: value != null
|
||
? Text(
|
||
value,
|
||
style: GoogleFonts.inter(
|
||
fontSize: 14,
|
||
color: const Color(0xFF78716C),
|
||
),
|
||
)
|
||
: Icon(
|
||
trailing,
|
||
color: danger ? const Color(0xFFFCA292) : const Color(0xFFA8A29E),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _divider() => Container(height: 1, color: const Color(0xFFF5F5F4));
|
||
|
||
Future<void> _delete(BuildContext context) async {
|
||
final ok = await showDeleteAccountConfirmationFlow(context);
|
||
if (ok != true || !context.mounted) return;
|
||
final res = await UserApi.deleteAccount(
|
||
app: currentBackendAppType(),
|
||
userId: UserState.userId.value,
|
||
);
|
||
if (!context.mounted) return;
|
||
if (res.isSuccess) {
|
||
ApiClient.instance.setUserToken(null);
|
||
UserState.clear();
|
||
Navigator.of(context).pop();
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text(
|
||
'Account deleted. Please restart the app and sign in again.',
|
||
),
|
||
),
|
||
);
|
||
} else {
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(SnackBar(content: Text(res.msg)));
|
||
}
|
||
}
|
||
}
|