feat:参照研听的基础工程

This commit is contained in:
jingyun
2026-06-18 15:02:28 +08:00
commit 364fc837b3
367 changed files with 16495 additions and 0 deletions
@@ -0,0 +1,122 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../common/domain/metal_type.dart';
import '../../market/data/mock_market_repository.dart';
import '../data/asset_models.dart';
import '../data/mock_asset_repository.dart';
final assetPortfolioControllerProvider =
StateNotifierProvider<AssetPortfolioController, PortfolioSummary>((ref) {
final holdings = ref.watch(mockAssetRepositoryProvider).loadHoldings();
final market = ref.watch(mockMarketRepositoryProvider);
return AssetPortfolioController(
holdings: holdings,
spotPriceFor: (metal) => market.quoteFor(metal).spotPrice,
changeFor: (metal) => market.quoteFor(metal).changeAmount,
);
});
class AssetPortfolioController extends StateNotifier<PortfolioSummary> {
AssetPortfolioController({
required List<GoldAssetHolding> holdings,
required double Function(MetalType metal) spotPriceFor,
required double Function(MetalType metal) changeFor,
this.recycleDiscount = 0.97,
}) : _holdings = holdings,
_spotPriceFor = spotPriceFor,
_changeFor = changeFor,
super(
_buildSummary(
holdings: holdings,
spotPriceFor: spotPriceFor,
changeFor: changeFor,
recycleDiscount: recycleDiscount,
),
);
final List<GoldAssetHolding> _holdings;
final double Function(MetalType metal) _spotPriceFor;
final double Function(MetalType metal) _changeFor;
final double recycleDiscount;
void addHolding(GoldAssetHolding holding) {
_holdings.add(holding);
state = _buildSummary(
holdings: _holdings,
spotPriceFor: _spotPriceFor,
changeFor: _changeFor,
recycleDiscount: recycleDiscount,
);
}
static PortfolioSummary _buildSummary({
required List<GoldAssetHolding> holdings,
required double Function(MetalType metal) spotPriceFor,
required double Function(MetalType metal) changeFor,
required double recycleDiscount,
}) {
final active = holdings
.where((holding) => holding.status == HoldingStatus.active)
.toList(growable: false);
final valuations = [
for (final holding in active)
HoldingValuation(
holding: holding,
materialValue: holding.materialValue(spotPriceFor(holding.metal)),
todayChange:
holding.weightGram * holding.purity * changeFor(holding.metal),
),
];
final totalValue = valuations.fold<double>(
0,
(sum, item) => sum + item.materialValue,
);
final todayChange = valuations.fold<double>(
0,
(sum, item) => sum + item.todayChange,
);
final totalCost = active.fold<double>(
0,
(sum, holding) => sum + (holding.costAmount ?? 0),
);
final totalWeight = active.fold<double>(
0,
(sum, holding) => sum + holding.weightGram,
);
return PortfolioSummary(
totalValue: totalValue,
todayChangeAmount: todayChange,
todayChangePercent: totalValue == 0 ? 0 : todayChange / totalValue * 100,
totalCost: totalCost,
totalWeightGram: totalWeight,
recycleReferenceValue: totalValue * recycleDiscount,
breakdowns: [
for (final metal in MetalType.values)
_breakdownFor(metal, active, spotPriceFor),
].where((item) => item.count > 0).toList(growable: false),
holdings: valuations,
);
}
static MetalBreakdown _breakdownFor(
MetalType metal,
List<GoldAssetHolding> holdings,
double Function(MetalType metal) spotPriceFor,
) {
final items = holdings.where((holding) => holding.metal == metal).toList();
return MetalBreakdown(
metal: metal,
value: items.fold<double>(
0,
(sum, holding) => sum + holding.materialValue(spotPriceFor(metal)),
),
weightGram: items.fold<double>(
0,
(sum, holding) => sum + holding.weightGram,
),
count: items.length,
);
}
}
+102
View File
@@ -0,0 +1,102 @@
import '../../../common/domain/metal_type.dart';
enum AssetCategory {
bar('金条'),
necklace('项链'),
ring('戒指'),
bracelet('手镯'),
bean('金豆'),
earring('耳环'),
silverware('银饰'),
platinumPiece('铂金件');
const AssetCategory(this.label);
final String label;
}
enum HoldingStatus { active, sold, gifted }
class GoldAssetHolding {
const GoldAssetHolding({
required this.id,
required this.name,
required this.metal,
required this.category,
required this.purity,
required this.purityLabel,
required this.weightGram,
required this.createdAt,
required this.updatedAt,
this.costAmount,
this.purchaseDate,
this.channel,
this.note,
this.status = HoldingStatus.active,
});
final String id;
final String name;
final MetalType metal;
final AssetCategory category;
final double purity;
final String purityLabel;
final double weightGram;
final double? costAmount;
final DateTime? purchaseDate;
final String? channel;
final String? note;
final DateTime createdAt;
final DateTime updatedAt;
final HoldingStatus status;
double materialValue(double spotPrice) => weightGram * purity * spotPrice;
}
class HoldingValuation {
const HoldingValuation({
required this.holding,
required this.materialValue,
required this.todayChange,
});
final GoldAssetHolding holding;
final double materialValue;
final double todayChange;
}
class MetalBreakdown {
const MetalBreakdown({
required this.metal,
required this.value,
required this.weightGram,
required this.count,
});
final MetalType metal;
final double value;
final double weightGram;
final int count;
}
class PortfolioSummary {
const PortfolioSummary({
required this.totalValue,
required this.todayChangeAmount,
required this.todayChangePercent,
required this.totalCost,
required this.totalWeightGram,
required this.recycleReferenceValue,
required this.breakdowns,
required this.holdings,
});
final double totalValue;
final double todayChangeAmount;
final double todayChangePercent;
final double totalCost;
final double totalWeightGram;
final double recycleReferenceValue;
final List<MetalBreakdown> breakdowns;
final List<HoldingValuation> holdings;
}
@@ -0,0 +1,73 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../common/domain/metal_type.dart';
import 'asset_models.dart';
final mockAssetRepositoryProvider = Provider<MockAssetRepository>(
(ref) => MockAssetRepository(),
);
class MockAssetRepository {
List<GoldAssetHolding> loadHoldings() {
final now = DateTime(2026, 6, 18, 16, 11);
return [
GoldAssetHolding(
id: 'holding_gold_bar_001',
name: '投资金条',
metal: MetalType.gold,
category: AssetCategory.bar,
purity: 0.9999,
purityLabel: '足金9999',
weightGram: 50,
costAmount: 43200,
purchaseDate: DateTime(2025, 10, 12),
channel: '银行',
createdAt: now.subtract(const Duration(days: 249)),
updatedAt: now,
),
GoldAssetHolding(
id: 'holding_gold_ring_001',
name: '素圈戒指',
metal: MetalType.gold,
category: AssetCategory.ring,
purity: 0.999,
purityLabel: '足金999',
weightGram: 8.6,
costAmount: 7820,
purchaseDate: DateTime(2024, 12, 4),
channel: '金店',
createdAt: now.subtract(const Duration(days: 196)),
updatedAt: now,
),
GoldAssetHolding(
id: 'holding_platinum_001',
name: 'PT950 项链',
metal: MetalType.platinum,
category: AssetCategory.platinumPiece,
purity: 0.95,
purityLabel: 'PT950',
weightGram: 12.3,
costAmount: 5200,
purchaseDate: DateTime(2023, 8, 21),
channel: '金店',
createdAt: now.subtract(const Duration(days: 667)),
updatedAt: now,
),
GoldAssetHolding(
id: 'holding_silver_001',
name: '银手镯',
metal: MetalType.silver,
category: AssetCategory.bracelet,
purity: 0.999,
purityLabel: '999银',
weightGram: 31.8,
costAmount: 980,
purchaseDate: DateTime(2024, 4, 7),
channel: '金店',
note: '材料价值偏低',
createdAt: now.subtract(const Duration(days: 802)),
updatedAt: now,
),
];
}
}
@@ -0,0 +1,136 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/domain/money.dart';
import '../../../common/widgets/adaptive.dart';
import '../application/asset_portfolio_controller.dart';
class AssetsPage extends ConsumerWidget {
const AssetsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final summary = ref.watch(assetPortfolioControllerProvider);
final cs = ShadTheme.of(context).colorScheme;
return SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Adaptive.contentMaxWidth,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('我的贵金属总估值', style: _mutedStyle(cs)),
const SizedBox(height: 10),
Text(
formatCny(summary.totalValue),
style: TextStyle(
fontSize: 34,
fontWeight: FontWeight.w800,
color: cs.foreground,
letterSpacing: 0,
),
),
const SizedBox(height: 8),
Text(
'今日 ${formatCny(summary.todayChangeAmount)} · ${summary.todayChangePercent.toStringAsFixed(2)}%',
style: TextStyle(
fontSize: 13,
color: summary.todayChangeAmount >= 0
? const Color(0xFFC0392B)
: const Color(0xFF2E8B6F),
),
),
],
),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final item in summary.breakdowns)
Chip(
label: Text(
'${item.metal.shortLabel} ${formatCny(item.value)}',
),
avatar: CircleAvatar(
backgroundColor: item.metal.color,
),
),
],
),
const SizedBox(height: 12),
_Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'我的持仓 · ${summary.holdings.length} 件 · ${formatGram(summary.totalWeightGram)}',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 12),
for (final item in summary.holdings)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Expanded(
child: Text(
'${item.holding.name} · ${item.holding.purityLabel} · ${formatGram(item.holding.weightGram)}',
style: TextStyle(color: cs.foreground),
),
),
Text(formatCny(item.materialValue)),
],
),
),
],
),
),
],
),
),
),
],
),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(12),
),
child: Padding(padding: const EdgeInsets.all(16), child: child),
);
}
}
TextStyle _mutedStyle(ShadColorScheme cs) {
return TextStyle(fontSize: 13, color: cs.mutedForeground, letterSpacing: 0);
}
@@ -0,0 +1,68 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../common/network/auth_interceptor.dart';
import '../../../common/storage/storage_keys.dart';
import '../../../common/storage/storage_service.dart';
import '../data/auth_models.dart';
final authProvider =
StateNotifierProvider<AuthController, AsyncValue<AuthState>>(
(ref) => AuthController(),
);
class AuthController extends StateNotifier<AsyncValue<AuthState>> {
AuthController() : super(const AsyncLoading()) {
_bootstrap();
}
Future<void> _bootstrap() async {
final token = await loadJwt();
if (token == null || token.isEmpty) {
state = const AsyncData(GuestAuthState());
return;
}
final phone = StorageService.to.getString(storageJinzhiMockLoggedIn) ?? '';
state = AsyncData(
LoggedInAuthState(
user: JinzhiUser(
phone: phone.isEmpty ? '13800000000' : phone,
nickname: '金值用户',
createdAt: DateTime(2026, 6, 18),
),
token: token,
),
);
}
Future<void> sendCode(String phone) async {
await Future<void>.delayed(const Duration(milliseconds: 200));
}
Future<void> verifyCode(String phone, String code) async {
final token = 'mock-jinzhi-token-$phone-$code';
await saveJwt(token);
await StorageService.to.setString(storageJinzhiMockLoggedIn, phone);
state = AsyncData(
LoggedInAuthState(
user: JinzhiUser(
phone: phone,
nickname: '金值用户',
createdAt: DateTime.now(),
),
token: token,
),
);
}
Future<void> logout() async {
await clearJwt();
await StorageService.to.setString(storageJinzhiMockLoggedIn, '');
state = const AsyncData(GuestAuthState());
}
Future<void> deleteAccount([String? code]) async {
await logout();
}
bool get isLoggedIn => state.valueOrNull is LoggedInAuthState;
}
+26
View File
@@ -0,0 +1,26 @@
class JinzhiUser {
const JinzhiUser({
required this.phone,
required this.nickname,
required this.createdAt,
});
final String phone;
final String nickname;
final DateTime createdAt;
}
sealed class AuthState {
const AuthState();
}
class GuestAuthState extends AuthState {
const GuestAuthState();
}
class LoggedInAuthState extends AuthState {
const LoggedInAuthState({required this.user, required this.token});
final JinzhiUser user;
final String token;
}
@@ -0,0 +1,916 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_remix/flutter_remix.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/network/api_error_message.dart';
import '../../../common/router/app_router.dart';
import '../../../common/theme/app_text.dart';
import '../../../common/theme/jinzhi_theme.dart';
import '../../../common/theme/spacing.dart';
import '../../../common/widgets/circle_back_button.dart';
import '../application/auth_controller.dart';
import '../data/auth_models.dart';
class DeleteAccountPage extends ConsumerWidget {
const DeleteAccountPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final authAsync = ref.watch(authProvider);
final cs = ShadTheme.of(context).colorScheme;
return authAsync.when(
loading: () => Scaffold(
backgroundColor: cs.background,
body: const Center(child: CircularProgressIndicator()),
),
error: (e, _) => Scaffold(
backgroundColor: cs.background,
body: Center(child: Text('加载失败:$e')),
),
data: (authState) {
if (authState is! LoggedInAuthState) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!context.mounted) return;
context.go(
'${AppRoutes.login}?next=${Uri.encodeComponent(AppRoutes.deleteAccount)}',
);
});
return Scaffold(
backgroundColor: cs.background,
body: const Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
backgroundColor: cs.background,
appBar: AppBar(
backgroundColor: cs.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: true,
title: Text(
'注销账号',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
leading: CircleBackButton(
onTap: () {
if (context.canPop()) {
context.pop();
} else {
context.go(AppRoutes.settings);
}
},
),
),
body: SafeArea(
top: false,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
Spacing.page,
8,
Spacing.page,
28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _WarnCard(),
const SizedBox(height: 28),
Text(
'注销前请确认',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 14),
const _DeleteCheckList(),
const SizedBox(height: 28),
_DangerButton(
label: '开始注销',
onPressed: () =>
_showVerifyDialog(context, ref, authState.user.phone),
),
const SizedBox(height: 14),
Text(
'注销后,账号资料与历史内容将无法恢复。',
textAlign: TextAlign.center,
style: AppText.meta.copyWith(
fontSize: 11.5,
color: cs.mutedForeground,
height: 1.6,
),
),
],
),
),
),
);
},
);
}
Future<void> _showVerifyDialog(
BuildContext context,
WidgetRef ref,
String phone,
) async {
final cs = ShadTheme.of(context).colorScheme;
final completed = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
backgroundColor: cs.card,
barrierColor: Colors.black.withValues(alpha: 0.5),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(Spacing.radiusCard),
),
),
builder: (_) => _VerifySheet(phone: phone),
);
if (!context.mounted || completed != true) return;
context.go(AppRoutes.deleteAccountCompleted);
}
}
String _maskPhone(String phone) {
if (phone.length < 7) return phone;
return '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}';
}
/// 危险主按钮(demo `.da-btn`destructive 实底 pill、52 高、16/600)。
class _DangerButton extends StatelessWidget {
const _DangerButton({required this.label, required this.onPressed});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return GestureDetector(
onTap: onPressed,
behavior: HitTestBehavior.opaque,
child: Container(
width: double.infinity,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: cs.destructive,
borderRadius: BorderRadius.circular(9999),
),
child: Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: cs.destructiveForeground,
),
),
),
);
}
}
/// sheet 内操作按钮(demo `.lo-btn`52 高、radius 12、16/600
/// primary=lime / danger=destructive / cancel=secondary+描边)。
enum _SheetBtnStyle { primary, danger, cancel }
class _SheetActionButton extends StatelessWidget {
const _SheetActionButton({
required this.label,
required this.style,
required this.onTap,
this.enabled = true,
});
final String label;
final _SheetBtnStyle style;
final VoidCallback onTap;
final bool enabled;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final Color bg;
final Color fg;
Border? border;
switch (style) {
case _SheetBtnStyle.primary:
bg = cs.primary;
fg = cs.primaryForeground;
case _SheetBtnStyle.danger:
bg = cs.destructive;
fg = cs.destructiveForeground;
case _SheetBtnStyle.cancel:
bg = cs.secondary;
fg = cs.foreground;
border = Border.all(color: cs.border);
}
return GestureDetector(
onTap: enabled ? onTap : null,
behavior: HitTestBehavior.opaque,
child: Opacity(
opacity: enabled ? 1 : 0.55,
child: Container(
width: double.infinity,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
border: border,
),
child: Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: fg,
),
),
),
),
);
}
}
class _PinInput extends StatefulWidget {
const _PinInput({
required this.controller,
required this.onCompleted,
this.enabled = true,
});
final TextEditingController controller;
final ValueChanged<String> onCompleted;
final bool enabled;
static const length = 6;
@override
State<_PinInput> createState() => _PinInputState();
}
class _PinInputState extends State<_PinInput> {
final _focusNode = FocusNode();
String _lastText = '';
@override
void initState() {
super.initState();
widget.controller.addListener(_onChanged);
}
@override
void didUpdateWidget(covariant _PinInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.enabled && !oldWidget.enabled) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focusNode.requestFocus();
});
}
}
@override
void dispose() {
widget.controller.removeListener(_onChanged);
_focusNode.dispose();
super.dispose();
}
void _onChanged() {
final value = widget.controller.text;
if (value == _lastText) return;
_lastText = value;
setState(() {});
if (value.length == _PinInput.length) {
FocusScope.of(context).unfocus();
widget.onCompleted(value);
}
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final filled = widget.controller.text;
return GestureDetector(
onTap: widget.enabled ? () => _focusNode.requestFocus() : null,
behavior: HitTestBehavior.opaque,
child: SizedBox(
height: 56,
child: Stack(
alignment: Alignment.center,
children: [
Row(
children: List.generate(_PinInput.length, (i) {
final hasChar = i < filled.length;
final isFocus = i == filled.length && widget.enabled;
return Expanded(
child: Padding(
padding: EdgeInsets.only(
right: i == _PinInput.length - 1 ? 0 : 12,
),
child: _PinCell(
char: hasChar ? filled[i] : '',
focused: isFocus,
cs: cs,
),
),
);
}),
),
Positioned.fill(
child: Opacity(
opacity: 0,
child: TextField(
controller: widget.controller,
focusNode: _focusNode,
enabled: widget.enabled,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(_PinInput.length),
],
showCursor: false,
autofocus: false,
decoration: const InputDecoration(
border: InputBorder.none,
counterText: '',
),
),
),
),
],
),
),
);
}
}
class _PinCell extends StatelessWidget {
const _PinCell({required this.char, required this.focused, required this.cs});
final String char;
final bool focused;
final ShadColorScheme cs;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 140),
height: 56,
decoration: BoxDecoration(
color: cs.background,
borderRadius: BorderRadius.circular(Spacing.radiusBtn),
border: Border.all(
color: focused ? cs.primary : cs.border,
width: focused ? 1.4 : 1,
),
),
alignment: Alignment.center,
child: Text(
char,
style: AppText.sectionTitle.copyWith(
fontSize: 19,
color: cs.foreground,
height: 1,
),
),
);
}
}
class _WarnCard extends StatelessWidget {
const _WarnCard();
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final b = ShadTheme.of(context).brightness;
return Container(
padding: const EdgeInsets.all(Spacing.cardPadding),
decoration: BoxDecoration(
color: destructiveSoft(b),
border: Border.all(color: destructiveSoftBorder(b)),
borderRadius: BorderRadius.circular(Spacing.radiusCard),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 42,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.destructive,
),
child: Icon(
FlutterRemix.alert_fill,
size: 21,
color: cs.destructiveForeground,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text.rich(
TextSpan(
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
height: 1.3,
color: cs.foreground,
),
children: [
const TextSpan(text: '此操作'),
TextSpan(
text: '不可恢复',
style: TextStyle(color: cs.destructive),
),
],
),
),
const SizedBox(height: 6),
Text(
'注销后,账号、阅读历史、个人数据将永久失效且无法找回。',
style: TextStyle(
fontSize: 13,
height: 1.6,
color: cs.mutedForeground,
),
),
],
),
),
],
),
);
}
}
class _DeleteCheckList extends StatelessWidget {
const _DeleteCheckList();
static const _items = [
('账户处于安全状态', '您的账户处于安全状态,无异常登录。'),
('账户信息将被清空', '您的个人账户相关信息将被清空且无法恢复。'),
('浏览记录将被清空', '您最近阅读的文章将被清空且无法恢复。'),
];
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(Spacing.radiusCard),
),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (var i = 0; i < _items.length; i++) ...[
if (i > 0) Divider(height: 1, thickness: 1, color: cs.border),
_DeleteCheckItem(
number: i + 1,
title: _items[i].$1,
desc: _items[i].$2,
),
],
],
),
);
}
}
class _DeleteCheckItem extends StatelessWidget {
const _DeleteCheckItem({
required this.number,
required this.title,
required this.desc,
});
final int number;
final String title;
final String desc;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 15, 16, 15),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 24,
height: 24,
margin: const EdgeInsets.only(top: 1),
alignment: Alignment.center,
decoration: BoxDecoration(
color: cs.accent,
borderRadius: BorderRadius.circular(7),
),
child: Text(
'$number',
style: AppText.meta.copyWith(
fontSize: 13,
fontWeight: FontWeight.w700,
color: cs.accentForeground,
height: 1,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: cs.foreground,
height: 1.4,
),
),
const SizedBox(height: 3),
Text(
desc,
style: TextStyle(
fontSize: 13,
color: cs.mutedForeground,
height: 1.55,
),
),
],
),
),
],
),
);
}
}
/// 注销短信验证底部 sheet(对齐 demo `deleteVerifySheet`):
/// 未发送(说明 + 发送验证码[primary]/取消)→ 已发送(OTP 6 格 + 重发倒计时 +
/// 确认注销[danger]/取消)→ 提交中(spinner)。
class _VerifySheet extends ConsumerStatefulWidget {
const _VerifySheet({required this.phone});
final String phone;
@override
ConsumerState<_VerifySheet> createState() => _VerifySheetState();
}
class _VerifySheetState extends ConsumerState<_VerifySheet> {
final _otpController = TextEditingController();
Timer? _timer;
int _countdown = 0;
bool _codeSent = false;
bool _sending = false;
bool _submitting = false;
String? _error;
@override
void dispose() {
_otpController.dispose();
_timer?.cancel();
super.dispose();
}
void _startCountdown() {
_timer?.cancel();
setState(() => _countdown = 60);
_timer = Timer.periodic(const Duration(seconds: 1), (t) {
if (!mounted) {
t.cancel();
return;
}
if (_countdown <= 1) {
t.cancel();
setState(() => _countdown = 0);
} else {
setState(() => _countdown -= 1);
}
});
}
Future<void> _sendCode() async {
if (_sending || _countdown > 0) return;
setState(() {
_sending = true;
_error = null;
});
try {
await ref.read(authProvider.notifier).sendCode(widget.phone);
if (!mounted) return;
setState(() {
_codeSent = true;
_otpController.clear();
});
_startCountdown();
} catch (e) {
if (!mounted) return;
setState(() {
_error = kratosDisplayMessage(e, fallback: '发送失败,请稍后重试');
});
} finally {
if (mounted) setState(() => _sending = false);
}
}
Future<void> _confirm() async {
final code = _otpController.text.trim().replaceAll(' ', '');
if (code.length != 6) {
setState(() => _error = '请输入6位验证码');
return;
}
setState(() {
_submitting = true;
_error = null;
});
try {
await ref.read(authProvider.notifier).deleteAccount(code);
if (!mounted) return;
Navigator.of(context).pop(true);
} catch (e) {
if (!mounted) return;
setState(() {
_error = kratosDisplayMessage(e, fallback: '注销失败,请重试');
});
} finally {
if (mounted) setState(() => _submitting = false);
}
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
// 键盘弹出时 sheet 跟随上移
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// grip
Center(
child: Container(
width: 38,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: cs.border,
borderRadius: BorderRadius.circular(9999),
),
),
),
Text(
'短信验证',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 8),
if (_submitting)
..._buildSubmitting(cs)
else if (!_codeSent)
..._buildSendStep(cs)
else
..._buildCodeStep(cs),
],
),
),
),
);
}
List<Widget> _buildSubmitting(ShadColorScheme cs) {
return [
Text(
'正在清除账户数据,请稍候',
style: TextStyle(
fontSize: 13,
height: 1.55,
color: cs.mutedForeground,
),
),
const SizedBox(height: 28),
Center(
child: SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(strokeWidth: 2.4, color: cs.primary),
),
),
const SizedBox(height: 28),
];
}
List<Widget> _buildSendStep(ShadColorScheme cs) {
return [
Text(
'为保障账户安全,注销前需验证您的手机号。验证码将发送至 ${_maskPhone(widget.phone)}',
style: TextStyle(
fontSize: 13,
height: 1.55,
color: cs.mutedForeground,
),
),
if (_error != null) ...[
const SizedBox(height: 10),
Text(_error!, style: TextStyle(fontSize: 13, color: cs.destructive)),
],
const SizedBox(height: 22),
_SheetActionButton(
label: _sending ? '发送中…' : '发送验证码',
style: _SheetBtnStyle.primary,
enabled: !_sending,
onTap: _sendCode,
),
const SizedBox(height: 12),
_SheetActionButton(
label: '取消',
style: _SheetBtnStyle.cancel,
enabled: !_sending,
onTap: () => Navigator.of(context).pop(false),
),
];
}
List<Widget> _buildCodeStep(ShadColorScheme cs) {
return [
Text(
'验证码已发送至 ${_maskPhone(widget.phone)}',
style: TextStyle(
fontSize: 13,
height: 1.55,
color: cs.mutedForeground,
),
),
const SizedBox(height: 20),
_PinInput(
controller: _otpController,
enabled: !_submitting,
onCompleted: (_) => _confirm(),
),
if (_error != null) ...[
const SizedBox(height: 10),
Text(_error!, style: TextStyle(fontSize: 13, color: cs.destructive)),
],
const SizedBox(height: 14),
Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _countdown > 0 || _sending ? null : _sendCode,
child: Text(
_countdown > 0 ? '$_countdown s 后可重新发送' : '重新发送',
style: TextStyle(
fontSize: 13,
color: _countdown > 0 ? cs.mutedForeground : cs.primary,
),
),
),
),
const SizedBox(height: 18),
_SheetActionButton(
label: '确认注销',
style: _SheetBtnStyle.danger,
enabled: !_submitting,
onTap: _confirm,
),
const SizedBox(height: 12),
_SheetActionButton(
label: '取消',
style: _SheetBtnStyle.cancel,
enabled: !_submitting,
onTap: () => Navigator.of(context).pop(false),
),
];
}
}
class DeleteAccountSuccessPage extends ConsumerWidget {
const DeleteAccountSuccessPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = ShadTheme.of(context).colorScheme;
return Scaffold(
backgroundColor: cs.background,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
Spacing.page,
72,
Spacing.page,
28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 72,
height: 72,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: cs.primary,
),
child: Icon(
FlutterRemix.check_line,
size: 34,
color: cs.primaryForeground,
),
),
),
const SizedBox(height: 24),
Text(
'账户已注销',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
height: 1.4,
color: cs.foreground,
),
),
const SizedBox(height: 10),
Text(
'您的账号及相关数据已清除完毕\n感谢您曾使用研听',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: cs.mutedForeground,
height: 1.7,
),
),
const SizedBox(height: 48),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
await ref.read(authProvider.notifier).logout();
if (context.mounted) context.go(AppRoutes.profile);
},
child: Container(
width: double.infinity,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: cs.primary,
borderRadius: BorderRadius.circular(9999),
),
child: Text(
'返回首页',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: cs.primaryForeground,
),
),
),
),
const Spacer(),
Text(
'如未来需再次使用,可重新注册账号\n但原历史数据无法迁移恢复',
textAlign: TextAlign.center,
style: AppText.meta.copyWith(
fontSize: 11,
color: cs.mutedForeground,
height: 1.7,
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,702 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_remix/flutter_remix.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:url_launcher/url_launcher_string.dart';
import '../../../common/config/app_h5_urls.dart';
import '../../../common/network/api_error_message.dart';
import '../../../common/router/app_router.dart';
import '../../../common/theme/spacing.dart';
import '../../../common/widgets/circle_back_button.dart';
import '../application/auth_controller.dart';
class LoginPage extends HookConsumerWidget {
const LoginPage({super.key, this.next});
final String? next;
@override
Widget build(BuildContext context, WidgetRef ref) {
final cs = ShadTheme.of(context).colorScheme;
final phone = useTextEditingController();
final codeCtrl = useTextEditingController();
final codeFocus = useFocusNode();
final onCodeStep = useState(false);
final agreed = useState(false);
final sending = useState(false);
final verifying = useState(false);
final countdown = useState(0);
final error = useState<String?>(null);
final timer = useRef<Timer?>(null);
useListenable(phone);
useListenable(codeCtrl);
useEffect(
() =>
() => timer.value?.cancel(),
const [],
);
String phoneDigits() => phone.text.replaceAll(RegExp(r'\D'), '');
Future<void> returnAfterLogin() async {
final target = next?.trim();
final router = GoRouter.of(context);
if (target == null || target.isEmpty) {
router.go(AppRoutes.profile);
return;
}
router.go(target);
}
void startCountdown() {
countdown.value = 60;
timer.value?.cancel();
timer.value = Timer.periodic(const Duration(seconds: 1), (t) {
if (countdown.value <= 1) {
t.cancel();
countdown.value = 0;
} else {
countdown.value--;
}
});
}
Future<void> doSend() async {
if (sending.value || countdown.value > 0) return;
if (phoneDigits().length != 11) {
error.value = '请输入正确的手机号';
return;
}
sending.value = true;
error.value = null;
try {
await ref.read(authProvider.notifier).sendCode(phoneDigits());
startCountdown();
onCodeStep.value = true;
codeFocus.requestFocus();
} catch (e) {
error.value = kratosDisplayMessage(e, fallback: '发送失败,请稍后重试');
} finally {
sending.value = false;
}
}
Future<void> doVerify() async {
if (verifying.value) return;
final code = codeCtrl.text.trim();
if (code.length != 6) {
error.value = '请输入 6 位验证码';
return;
}
verifying.value = true;
error.value = null;
try {
await ref.read(authProvider.notifier).verifyCode(phoneDigits(), code);
if (!context.mounted) return;
await returnAfterLogin();
} catch (e) {
error.value = kratosDisplayMessage(e, fallback: '验证失败,请重试');
} finally {
verifying.value = false;
}
}
void onBack() {
if (onCodeStep.value) {
onCodeStep.value = false;
error.value = null;
} else if (context.canPop()) {
context.pop();
} else {
context.go(AppRoutes.profile);
}
}
void showPrivacySheet(VoidCallback onAgreed) {
showModalBottomSheet<void>(
context: context,
backgroundColor: cs.card,
barrierColor: Colors.black.withValues(alpha: 0.6),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(Spacing.radiusCard),
),
),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 28, 22, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'请阅读并同意以下条款',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: cs.foreground,
),
),
const SizedBox(height: 14),
Text.rich(
textAlign: TextAlign.center,
_policySpan(context, cs, fontSize: 12.5),
),
const SizedBox(height: 22),
_PrimaryButton(
label: '同意并继续',
enabled: true,
onTap: () {
Navigator.of(ctx).pop();
onAgreed();
},
),
],
),
),
),
);
}
void onSendTap() {
if (sending.value || countdown.value > 0) return;
if (phoneDigits().length != 11) {
error.value = '请输入正确的手机号';
return;
}
error.value = null;
if (!agreed.value) {
showPrivacySheet(() {
agreed.value = true;
doSend();
});
return;
}
doSend();
}
final phoneReady = phoneDigits().length == 11;
final codeReady = codeCtrl.text.trim().length == 6;
return Scaffold(
backgroundColor: cs.background,
appBar: AppBar(
backgroundColor: cs.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
automaticallyImplyLeading: false,
toolbarHeight: 46,
titleSpacing: 0,
title: CircleBackButton(onTap: onBack),
),
body: SafeArea(
top: false,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(22, 8, 22, 28),
child: onCodeStep.value
? _CodeStep(
cs: cs,
phone: phoneDigits(),
codeCtrl: codeCtrl,
codeFocus: codeFocus,
countdown: countdown.value,
verifying: verifying.value,
codeReady: codeReady,
error: error.value,
onVerify: doVerify,
onResend: doSend,
)
: _PhoneStep(
cs: cs,
phone: phone,
sending: sending.value,
countdown: countdown.value,
phoneReady: phoneReady,
error: error.value,
agreed: agreed.value,
onToggleAgreed: () => agreed.value = !agreed.value,
onSend: onSendTap,
),
),
),
);
}
}
class _PhoneStep extends StatelessWidget {
const _PhoneStep({
required this.cs,
required this.phone,
required this.sending,
required this.countdown,
required this.phoneReady,
required this.agreed,
required this.onToggleAgreed,
required this.error,
required this.onSend,
});
final ShadColorScheme cs;
final TextEditingController phone;
final bool sending;
final int countdown;
final bool phoneReady;
final bool agreed;
final VoidCallback onToggleAgreed;
final String? error;
final VoidCallback onSend;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SheetTitle('手机号登录', cs),
const SizedBox(height: 8),
_SheetSub('未注册的手机号验证后将自动创建研听账号。', cs),
const SizedBox(height: 18),
Row(
children: [
SizedBox(
height: 50,
child: Center(
child: Text(
'+86',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: cs.foreground,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
),
const SizedBox(width: 10),
Expanded(
child: _BoxedField(controller: phone, hint: '请输入手机号', cs: cs),
),
],
),
if (error != null) ...[
const SizedBox(height: 10),
_ErrorText(error!, cs),
],
const SizedBox(height: 18),
_PrimaryButton(
label: countdown > 0 ? '$countdown s 后可重新发送' : '获取验证码',
enabled: !sending && countdown == 0,
loading: sending,
dim: !phoneReady,
onTap: onSend,
),
const SizedBox(height: 18),
_AgreeRow(agreed: agreed, onToggle: onToggleAgreed, cs: cs),
],
);
}
}
class _CodeStep extends StatelessWidget {
const _CodeStep({
required this.cs,
required this.phone,
required this.codeCtrl,
required this.codeFocus,
required this.countdown,
required this.verifying,
required this.codeReady,
required this.error,
required this.onVerify,
required this.onResend,
});
final ShadColorScheme cs;
final String phone;
final TextEditingController codeCtrl;
final FocusNode codeFocus;
final int countdown;
final bool verifying;
final bool codeReady;
final String? error;
final VoidCallback onVerify;
final VoidCallback onResend;
String get _masked => phone.length == 11
? '${phone.substring(0, 3)}****${phone.substring(7)}'
: phone;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SheetTitle('输入验证码', cs),
const SizedBox(height: 8),
_SheetSub('验证码已发送至 +86 $_masked', cs),
const SizedBox(height: 20),
_OtpInput(controller: codeCtrl, focusNode: codeFocus, cs: cs),
if (error != null) ...[
const SizedBox(height: 12),
_ErrorText(error!, cs),
],
const SizedBox(height: 18),
_PrimaryButton(
label: '验证并登录',
enabled: !verifying,
loading: verifying,
dim: !codeReady,
onTap: onVerify,
),
const SizedBox(height: 16),
Center(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: countdown > 0 ? null : onResend,
child: Text(
countdown > 0 ? '$countdown s 后可重新发送' : '重新发送',
style: TextStyle(
fontSize: 13,
color: countdown > 0 ? cs.mutedForeground : cs.primary,
),
),
),
),
],
);
}
}
class _SheetTitle extends StatelessWidget {
const _SheetTitle(this.text, this.cs);
final String text;
final ShadColorScheme cs;
@override
Widget build(BuildContext context) => Text(
text,
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
);
}
class _SheetSub extends StatelessWidget {
const _SheetSub(this.text, this.cs);
final String text;
final ShadColorScheme cs;
@override
Widget build(BuildContext context) => Text(
text,
style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground),
);
}
class _ErrorText extends StatelessWidget {
const _ErrorText(this.text, this.cs);
final String text;
final ShadColorScheme cs;
@override
Widget build(BuildContext context) =>
Text(text, style: TextStyle(fontSize: 13, color: cs.destructive));
}
class _BoxedField extends StatelessWidget {
const _BoxedField({
required this.controller,
required this.hint,
required this.cs,
});
final TextEditingController controller;
final String hint;
final ShadColorScheme cs;
@override
Widget build(BuildContext context) {
return Container(
height: 50,
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: cs.background,
borderRadius: BorderRadius.circular(Spacing.radiusBtn),
border: Border.all(color: cs.input, width: 1),
),
child: TextField(
controller: controller,
keyboardType: TextInputType.phone,
inputFormatters: [_PhoneNumberFormatter()],
style: TextStyle(
fontSize: 16,
color: cs.foreground,
fontFeatures: const [FontFeature.tabularFigures()],
),
cursorColor: cs.primary,
decoration: InputDecoration(
isCollapsed: true,
border: InputBorder.none,
hintText: hint,
hintStyle: TextStyle(fontSize: 15, color: cs.mutedForeground),
),
),
);
}
}
class _PhoneNumberFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
var d = newValue.text.replaceAll(RegExp(r'\D'), '');
if (d.length > 11) d = d.substring(0, 11);
final b = StringBuffer();
for (var i = 0; i < d.length; i++) {
if (i == 3 || i == 7) b.write(' ');
b.write(d[i]);
}
final s = b.toString();
return TextEditingValue(
text: s,
selection: TextSelection.collapsed(offset: s.length),
);
}
}
class _OtpInput extends StatelessWidget {
const _OtpInput({
required this.controller,
required this.focusNode,
required this.cs,
});
final TextEditingController controller;
final FocusNode focusNode;
final ShadColorScheme cs;
@override
Widget build(BuildContext context) {
final code = controller.text;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: focusNode.requestFocus,
child: SizedBox(
height: 54,
child: Stack(
children: [
Row(
children: [
for (var i = 0; i < 6; i++) ...[
if (i > 0) const SizedBox(width: 8),
Expanded(
child: _OtpCell(
digit: i < code.length ? code[i] : '',
filled: i < code.length,
cs: cs,
),
),
],
],
),
Positioned.fill(
child: TextField(
controller: controller,
focusNode: focusNode,
keyboardType: TextInputType.number,
maxLength: 6,
showCursor: false,
autofocus: true,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
style: const TextStyle(color: Colors.transparent),
decoration: const InputDecoration(
counterText: '',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
),
),
),
],
),
),
);
}
}
class _OtpCell extends StatelessWidget {
const _OtpCell({required this.digit, required this.filled, required this.cs});
final String digit;
final bool filled;
final ShadColorScheme cs;
@override
Widget build(BuildContext context) {
return Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: cs.background,
borderRadius: BorderRadius.circular(Spacing.radiusBtn),
border: Border.all(color: filled ? cs.foreground : cs.input, width: 1),
),
child: Text(
digit,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: cs.foreground,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
);
}
}
class _AgreeRow extends StatelessWidget {
const _AgreeRow({
required this.agreed,
required this.onToggle,
required this.cs,
});
final bool agreed;
final VoidCallback onToggle;
final ShadColorScheme cs;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onToggle,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 18,
height: 18,
margin: const EdgeInsets.only(top: 1),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Spacing.radiusBadge),
border: Border.all(
color: agreed ? cs.primary : cs.border,
width: 1.2,
),
color: agreed ? cs.primary : Colors.transparent,
),
child: agreed
? Icon(
FlutterRemix.check_line,
size: 13,
color: cs.primaryForeground,
)
: null,
),
const SizedBox(width: 8),
Expanded(child: Text.rich(_policySpan(context, cs))),
],
),
);
}
}
TextSpan _policySpan(
BuildContext context,
ShadColorScheme cs, {
double fontSize = 11.5,
}) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final privacyUrl = AppH5UrlsHelper.buildUrlWithNight(
AppH5Urls.privacyUrl,
isDark,
);
final protocolUrl = AppH5UrlsHelper.buildUrlWithNight(
AppH5Urls.userProtocolUrl,
isDark,
);
TextSpan link(String text, String url) => TextSpan(
text: text,
style: TextStyle(
color: cs.foreground,
fontSize: fontSize,
decoration: TextDecoration.underline,
decorationColor: cs.foreground,
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrlString(
AppH5UrlsHelper.withCacheBuster(url),
mode: LaunchMode.inAppBrowserView,
),
);
return TextSpan(
style: TextStyle(
fontSize: fontSize,
height: 1.6,
color: cs.mutedForeground,
),
children: [
const TextSpan(text: '已阅读并同意 '),
link('《用户协议》', protocolUrl),
const TextSpan(text: ''),
link('《隐私政策》', privacyUrl),
],
);
}
class _PrimaryButton extends StatelessWidget {
const _PrimaryButton({
required this.label,
required this.enabled,
required this.onTap,
this.loading = false,
this.dim = false,
});
final String label;
final bool enabled;
final bool loading;
final bool dim;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final active = enabled && !dim;
return SizedBox(
width: double.infinity,
child: GestureDetector(
onTap: enabled ? onTap : null,
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 15),
alignment: Alignment.center,
decoration: BoxDecoration(
color: active ? cs.primary : cs.muted,
borderRadius: BorderRadius.circular(9999),
),
child: loading
? SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: active ? cs.primaryForeground : cs.mutedForeground,
),
)
: Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: active ? cs.primaryForeground : cs.mutedForeground,
),
),
),
),
);
}
}
@@ -0,0 +1,176 @@
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../common/config/shorebird_service.dart';
import '../../../common/network/auth_interceptor.dart';
import '../../../common/network/dio_provider.dart';
import '../../../common/router/app_router.dart';
import '../../../common/services/device_header_service.dart';
import '../../../common/storage/storage_keys.dart';
import '../../../common/storage/storage_service.dart';
import '../../../common/theme/theme_controller.dart';
class DebugInfoPage extends ConsumerWidget {
const DebugInfoPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appConfig = ref.watch(appConfigProvider);
final dio = ref.watch(dioProvider);
final themeMode = ref.watch(themeModeControllerProvider);
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(AppRoutes.debug);
}
},
icon: const Icon(Icons.arrow_back_ios_new),
),
title: const Text('Info'),
),
body: FutureBuilder<_DebugInfoSnapshot>(
future: _loadSnapshot(
apiBaseUrl: appConfig.apiBaseUrl,
themeMode: themeMode.name,
dio: dio,
),
builder: (context, snapshot) {
final text = snapshot.data?.text ?? 'loading...';
return Padding(
padding: const EdgeInsets.all(16),
child: SelectableText(text, style: const TextStyle(fontSize: 12)),
);
},
),
);
}
}
Future<_DebugInfoSnapshot> _loadSnapshot({
required String apiBaseUrl,
required String themeMode,
required Dio dio,
}) async {
await DeviceHeaderService.to.ensureInitialized();
final packageInfo = await PackageInfo.fromPlatform();
final deviceInfo = await _readDeviceInfo();
final storage = StorageService.to;
final jwt = await loadJwt();
final commonHeaders = DeviceHeaderService.to.commonHeaders();
final platformHeaders = DeviceHeaderService.to.platformIdentityHeaders();
final headers = <String, dynamic>{
...commonHeaders,
...platformHeaders,
...dio.options.headers,
};
final gitVersion = const String.fromEnvironment(
'GITVERSION',
defaultValue: 'unknown',
);
final patch = ShorebirdService.instance.currentPatch;
final jwtLine = jwt?.isNotEmpty == true ? 'Bearer <redacted>' : '';
final text = StringBuffer()
..writeln('[build]')
..writeln('app_name: ${packageInfo.appName}')
..writeln('mode: ${kReleaseMode ? 'release' : 'debug'}')
..writeln('package_name: ${packageInfo.packageName}')
..writeln('version: ${packageInfo.version}')
..writeln('build_number: ${packageInfo.buildNumber}')
..writeln('theme_mode: $themeMode')
..writeln('platform: ${defaultTargetPlatform.name}')
..writeln('is_web: $kIsWeb')
..writeln()
..writeln('[environment]')
..writeln('api_base: $apiBaseUrl')
..writeln(
'connect_timeout_ms: ${dio.options.connectTimeout?.inMilliseconds}',
)
..writeln(
'receive_timeout_ms: ${dio.options.receiveTimeout?.inMilliseconds}',
)
..writeln('response_type: ${dio.options.responseType.name}')
..writeln('git_version: $gitVersion')
..writeln()
..writeln('[storage]')
..writeln('privacy_agreed: ${storage.getBool(storagePrivacyAgreed)}')
..writeln('ios_startup_seen: ${storage.getBool(storageIosStartupSeen)}')
..writeln('device_id: ${DeviceHeaderService.to.deviceId}')
..writeln('android_id: ${storage.getString(storageAndroidId) ?? ''}')
..writeln('device_token: ${storage.getString(storageDeviceToken) ?? ''}')
..writeln('android_oaid: ${storage.getString(storageAndroidOaid) ?? ''}')
..writeln('android_imei: ${storage.getString(storageAndroidImei) ?? ''}')
..writeln('android_mac: ${storage.getString(storageAndroidMac) ?? ''}')
..writeln('ios_idfa: ${storage.getString(storageIosIdfa) ?? ''}')
..writeln('ios_paid: ${storage.getString(storageIosPaid) ?? ''}')
..writeln()
..writeln('[auth]')
..writeln('jwt: $jwtLine')
..writeln()
..writeln('[headers]')
..writeln('common: $commonHeaders')
..writeln('platform: $platformHeaders')
..writeln('dio: $headers')
..writeln()
..writeln('[shorebird]')
..writeln('current_patch: ${patch?.number ?? 'none'}')
..writeln('patch: ${patch ?? 'none'}')
..writeln()
..writeln('[device]')
..writeln(deviceInfo);
return _DebugInfoSnapshot(text.toString());
}
Future<String> _readDeviceInfo() async {
try {
if (!kIsWeb &&
defaultTargetPlatform == TargetPlatform.android &&
!StorageService.to.getBool(storagePrivacyAgreed)) {
return 'privacy policy not accepted; device info not read';
}
final plugin = DeviceInfoPlugin();
if (kIsWeb) {
final info = await plugin.webBrowserInfo;
return info.data.toString();
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
final info = await plugin.androidInfo;
return info.data.toString();
case TargetPlatform.iOS:
final info = await plugin.iosInfo;
return info.data.toString();
case TargetPlatform.macOS:
final info = await plugin.macOsInfo;
return info.data.toString();
case TargetPlatform.windows:
final info = await plugin.windowsInfo;
return info.data.toString();
case TargetPlatform.linux:
final info = await plugin.linuxInfo;
return info.data.toString();
case TargetPlatform.fuchsia:
return 'fuchsia';
}
} catch (error) {
return 'unavailable: $error';
}
}
class _DebugInfoSnapshot {
const _DebugInfoSnapshot(this.text);
final String text;
}
@@ -0,0 +1,201 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/router/app_router.dart';
import '../../../common/theme/app_text.dart';
import '../../../common/theme/spacing.dart';
class DebugPage extends StatelessWidget {
const DebugPage({super.key});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
backgroundColor: cs.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(
'调试工具',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
leading: IconButton(
icon: Icon(Icons.arrow_back_ios_new, color: cs.foreground, size: 18),
onPressed: () => context.pop(),
),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(Spacing.page, 8, Spacing.page, 24),
children: [
_Section(
title: '状态',
child: _Card(
child: Text(
kReleaseMode ? 'release' : 'debug',
style: AppText.body.copyWith(color: cs.mutedForeground),
),
),
),
_Section(
title: '入口',
child: _Card(
children: [
_Row(
icon: Icons.data_object_outlined,
title: '请求',
subtitle: '查看 Dio 请求和响应',
onTap: () => context.push(AppRoutes.fancyDioInspector),
),
_Row(
icon: Icons.info_outline,
title: '应用信息',
subtitle: '版本、包名和构建信息',
onTap: () => context.push(AppRoutes.debugInfo),
),
_Row(
icon: Icons.text_fields,
title: 'Typography',
subtitle: '查看字号和文本样式基线',
onTap: () => context.push(AppRoutes.debugTypography),
),
_Row(
icon: Icons.system_update_alt_outlined,
title: 'Shorebird',
subtitle: '查看补丁与更新状态',
onTap: () => context.push(AppRoutes.debugShorebird),
),
],
),
),
],
),
);
}
}
class _Section extends StatelessWidget {
const _Section({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: Spacing.sectionGap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 10),
child: Text(
title,
style: AppText.meta.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: cs.mutedForeground,
),
),
),
child,
],
),
);
}
}
class _Card extends StatelessWidget {
const _Card({this.child, this.children = const []});
final Widget? child;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(Spacing.radiusBase),
),
clipBehavior: Clip.antiAlias,
child:
child ??
Column(
children: [
for (var i = 0; i < children.length; i++) ...[
if (i > 0) Divider(height: 1, thickness: 1, color: cs.border),
children[i],
],
],
),
);
}
}
class _Row extends StatelessWidget {
const _Row({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Row(
children: [
Icon(icon, size: 20, color: cs.foreground),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppText.body.copyWith(
color: cs.foreground,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: AppText.meta.copyWith(
color: cs.mutedForeground,
fontSize: 12.5,
),
),
],
),
),
Icon(Icons.chevron_right, size: 18, color: cs.mutedForeground),
],
),
),
),
);
}
}
@@ -0,0 +1,420 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shorebird_code_push/shorebird_code_push.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/config/shorebird_service.dart';
import '../../../common/router/app_router.dart';
import '../../../common/theme/app_text.dart';
import '../../../common/theme/spacing.dart';
class DebugShorebirdPage extends StatefulWidget {
const DebugShorebirdPage({super.key});
@override
State<DebugShorebirdPage> createState() => _DebugShorebirdPageState();
}
class _DebugShorebirdPageState extends State<DebugShorebirdPage> {
final ShorebirdService _shorebird = ShorebirdService.instance;
late final bool _isUpdaterAvailable;
var _currentTrack = UpdateTrack.stable;
var _isCheckingForUpdates = false;
Patch? _currentPatch;
@override
void initState() {
super.initState();
_isUpdaterAvailable = _shorebird.isAvailable;
_loadCurrentPatch();
}
Future<void> _loadCurrentPatch() async {
try {
await _shorebird.initCurrentPatch();
if (!mounted) return;
setState(() => _currentPatch = _shorebird.currentPatch);
} catch (error) {
debugPrint('Error reading current patch: $error');
}
}
Future<void> _checkForUpdate() async {
if (_isCheckingForUpdates) return;
try {
setState(() => _isCheckingForUpdates = true);
final status = await _shorebird.checkForUpdate(track: _currentTrack);
if (!mounted) return;
switch (status) {
case UpdateStatus.upToDate:
_showNoUpdateAvailableBanner();
break;
case UpdateStatus.outdated:
_showUpdateAvailableBanner();
break;
case UpdateStatus.restartRequired:
_showRestartBanner();
break;
case UpdateStatus.unavailable:
_showUnavailableBanner();
break;
}
} catch (error) {
debugPrint('Error checking for update: $error');
_showErrorBanner(error);
} finally {
if (mounted) {
setState(() => _isCheckingForUpdates = false);
}
}
}
void _showDownloadingBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
const MaterialBanner(
content: Text('正在下载更新...'),
actions: [
SizedBox(height: 14, width: 14, child: CircularProgressIndicator()),
],
),
);
}
void _showUpdateAvailableBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: Text('当前 ${_currentTrack.name} track 有可用更新。'),
actions: [
TextButton(
onPressed: () async {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
await _downloadUpdate();
if (!mounted) return;
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Download'),
),
],
),
);
}
void _showNoUpdateAvailableBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: Text('当前 ${_currentTrack.name} track 没有可用更新。'),
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
),
],
),
);
}
void _showRestartBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: const Text('新的 patch 已准备好,请重启应用。'),
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
),
],
),
);
}
void _showUnavailableBanner() {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: const Text('当前构建不可用 Shorebird。'),
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
),
],
),
);
}
void _showErrorBanner(Object error) {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: Text('检查更新时发生错误:$error'),
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
),
],
),
);
}
Future<void> _downloadUpdate() async {
_showDownloadingBanner();
try {
await _shorebird.downloadUpdate(track: _currentTrack);
if (!mounted) return;
setState(() => _currentPatch = _shorebird.currentPatch);
_showRestartBanner();
} on UpdateException catch (error) {
_showErrorBanner(error.message);
} catch (error) {
_showErrorBanner(error);
}
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
backgroundColor: cs.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(
'Shorebird',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
leading: IconButton(
icon: Icon(Icons.arrow_back_ios_new, color: cs.foreground, size: 18),
onPressed: () {
if (context.canPop()) {
context.pop();
} else {
context.go(AppRoutes.debug);
}
},
),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(Spacing.page, 8, Spacing.page, 24),
children: [
if (!_isUpdaterAvailable) ...[
_Card(
child: Text(
'当前构建未接入 Shorebird。请确认应用是通过 `shorebird release` 生成的 release 包,然后再检查更新与 patch 状态。',
style: AppText.body.copyWith(color: cs.mutedForeground),
),
),
const SizedBox(height: Spacing.cardGap),
],
_Section(
title: '当前状态',
child: _Card(
child: Column(
children: [
_InfoTile(
title: '当前 patch 版本',
value: _currentPatch != null
? '${_currentPatch!.number}'
: '未安装 patch',
),
Divider(height: 1, thickness: 1, color: cs.border),
_InfoTile(
title: '更新器可用',
value: _isUpdaterAvailable ? '' : '',
),
],
),
),
),
_Section(
title: 'Track 选择',
child: _Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'选择要检查和下载的 track。',
style: AppText.body.copyWith(color: cs.mutedForeground),
),
const SizedBox(height: 12),
SegmentedButton<UpdateTrack>(
segments: const [
ButtonSegment(
label: Text('Stable'),
value: UpdateTrack.stable,
),
ButtonSegment(
label: Text('Beta'),
value: UpdateTrack.beta,
),
ButtonSegment(
label: Text('Staging'),
value: UpdateTrack.staging,
),
],
selected: {_currentTrack},
onSelectionChanged: (tracks) {
setState(() => _currentTrack = tracks.single);
},
),
],
),
),
),
),
_Section(
title: '操作',
child: _Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: FilledButton(
onPressed: _isCheckingForUpdates
? null
: _checkForUpdate,
child: _isCheckingForUpdates
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('检查更新'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: _downloadUpdate,
child: const Text('下载更新'),
),
),
],
),
),
),
),
],
),
);
}
}
class _Section extends StatelessWidget {
const _Section({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: Spacing.sectionGap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 10),
child: Text(
title,
style: AppText.meta.copyWith(
fontSize: 13,
fontWeight: FontWeight.w600,
color: cs.mutedForeground,
),
),
),
child,
],
),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(Spacing.radiusBase),
),
clipBehavior: Clip.antiAlias,
child: child,
);
}
}
class _InfoTile extends StatelessWidget {
const _InfoTile({required this.title, required this.value});
final String title;
final String value;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppText.meta.copyWith(color: cs.mutedForeground),
),
const SizedBox(height: 4),
Text(
value,
style: AppText.body.copyWith(
color: cs.foreground,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
);
}
}
@@ -0,0 +1,24 @@
import 'package:fancy_dio_inspector/fancy_dio_inspector.dart';
import 'package:flutter/material.dart';
class FancyDioInspectorPage extends StatelessWidget {
const FancyDioInspectorPage({super.key});
@override
Widget build(BuildContext context) {
return ScaffoldMessenger(
child: Scaffold(
appBar: AppBar(
title: const Text('请求'),
leading: Navigator.of(context).canPop()
? IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.chevron_left),
)
: null,
),
body: const FancyDioInspectorView(),
),
);
}
}
@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/theme/app_text.dart';
import '../../../common/theme/spacing.dart';
class TypographyTestPage extends StatelessWidget {
const TypographyTestPage({super.key});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
const samples = <_Sample>[
_Sample('appTitle', AppText.appTitle),
_Sample('sectionTitle', AppText.sectionTitle),
_Sample('cardTitle', AppText.cardTitle),
_Sample('listTitle', AppText.listTitle),
_Sample('body', AppText.body),
_Sample('meta', AppText.meta),
_Sample('chip', AppText.chip),
_Sample('badge', AppText.badge),
];
return Scaffold(
appBar: AppBar(
backgroundColor: cs.background,
surfaceTintColor: Colors.transparent,
elevation: 0,
scrolledUnderElevation: 0,
title: Text(
'Typography',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
leading: IconButton(
icon: Icon(Icons.arrow_back_ios_new, color: cs.foreground, size: 18),
onPressed: () => context.pop(),
),
),
body: ListView(
padding: const EdgeInsets.fromLTRB(Spacing.page, 8, Spacing.page, 24),
children: [
for (final sample in samples) ...[
Container(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(Spacing.radiusBase),
),
clipBehavior: Clip.antiAlias,
padding: const EdgeInsets.all(16),
margin: const EdgeInsets.only(bottom: Spacing.cardGap),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
sample.name,
style: AppText.meta.copyWith(color: cs.mutedForeground),
),
const SizedBox(height: 8),
Text('金值 / Jinzhi / Typography', style: sample.style),
],
),
),
],
],
),
);
}
}
class _Sample {
const _Sample(this.name, this.style);
final String name;
final TextStyle style;
}
@@ -0,0 +1,123 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../common/domain/metal_type.dart';
import '../data/market_models.dart';
import '../data/mock_market_repository.dart';
import 'market_controller.dart';
final exchangeCalculatorControllerProvider =
StateNotifierProvider<
ExchangeCalculatorController,
ExchangeCalculatorState
>((ref) {
final market = ref.watch(marketControllerProvider);
final repository = ref.watch(mockMarketRepositoryProvider);
return ExchangeCalculatorController(
repository: repository,
initialMetal: market.selectedMetal,
);
});
class ExchangeCalculatorState {
const ExchangeCalculatorState({
required this.metal,
required this.channels,
required this.selectedChannel,
required this.gram,
required this.amount,
required this.swapped,
});
final MetalType metal;
final List<ExchangeChannel> channels;
final ExchangeChannel selectedChannel;
final double gram;
final double amount;
final bool swapped;
ExchangeCalculatorState copyWith({
MetalType? metal,
List<ExchangeChannel>? channels,
ExchangeChannel? selectedChannel,
double? gram,
double? amount,
bool? swapped,
}) {
return ExchangeCalculatorState(
metal: metal ?? this.metal,
channels: channels ?? this.channels,
selectedChannel: selectedChannel ?? this.selectedChannel,
gram: gram ?? this.gram,
amount: amount ?? this.amount,
swapped: swapped ?? this.swapped,
);
}
}
class ExchangeCalculatorController
extends StateNotifier<ExchangeCalculatorState> {
ExchangeCalculatorController({
required MockMarketRepository repository,
required MetalType initialMetal,
}) : _repository = repository,
super(_initialState(repository, initialMetal));
final MockMarketRepository _repository;
void selectMetal(MetalType metal) {
final channels = _repository.channelsFor(metal);
final selected = channels.first;
state = state.copyWith(
metal: metal,
channels: channels,
selectedChannel: selected,
amount: state.gram * selected.pricePerGram,
);
}
void selectChannel(String channelId) {
final selected = state.channels.firstWhere(
(channel) => channel.id == channelId,
orElse: () => state.channels.first,
);
state = state.copyWith(
selectedChannel: selected,
amount: state.gram * selected.pricePerGram,
);
}
void setGram(double gram) {
state = state.copyWith(
gram: gram,
amount: gram * state.selectedChannel.pricePerGram,
);
}
void setAmount(double amount) {
state = state.copyWith(
amount: amount,
gram: amount / state.selectedChannel.pricePerGram,
);
}
void toggleSwapped() {
state = state.copyWith(swapped: !state.swapped);
}
static ExchangeCalculatorState _initialState(
MockMarketRepository repository,
MetalType metal,
) {
final channels = repository.channelsFor(metal);
final selected = channels.first;
const gram = 50.0;
return ExchangeCalculatorState(
metal: metal,
channels: channels,
selectedChannel: selected,
gram: gram,
amount: gram * selected.pricePerGram,
swapped: false,
);
}
}
@@ -0,0 +1,73 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../common/domain/metal_type.dart';
import '../data/market_models.dart';
import '../data/mock_market_repository.dart';
final marketControllerProvider =
StateNotifierProvider<MarketController, MarketState>((ref) {
final repository = ref.watch(mockMarketRepositoryProvider);
return MarketController(repository);
});
class MarketState {
const MarketState({
required this.selectedMetal,
required this.period,
required this.quote,
required this.points,
required this.quotes,
});
final MetalType selectedMetal;
final MarketPeriod period;
final MetalQuote quote;
final List<MarketPoint> points;
final List<MetalQuote> quotes;
MarketState copyWith({
MetalType? selectedMetal,
MarketPeriod? period,
MetalQuote? quote,
List<MarketPoint>? points,
List<MetalQuote>? quotes,
}) {
return MarketState(
selectedMetal: selectedMetal ?? this.selectedMetal,
period: period ?? this.period,
quote: quote ?? this.quote,
points: points ?? this.points,
quotes: quotes ?? this.quotes,
);
}
}
class MarketController extends StateNotifier<MarketState> {
MarketController(this._repository)
: super(
MarketState(
selectedMetal: MetalType.gold,
period: MarketPeriod.h24,
quote: _repository.quoteFor(MetalType.gold),
points: _repository.chartFor(MetalType.gold, MarketPeriod.h24),
quotes: _repository.getQuotes(),
),
);
final MockMarketRepository _repository;
void selectMetal(MetalType metal) {
state = state.copyWith(
selectedMetal: metal,
quote: _repository.quoteFor(metal),
points: _repository.chartFor(metal, state.period),
);
}
void selectPeriod(MarketPeriod period) {
state = state.copyWith(
period: period,
points: _repository.chartFor(state.selectedMetal, period),
);
}
}
@@ -0,0 +1,74 @@
import '../../../common/domain/metal_type.dart';
enum MarketPeriod {
h24('24h', '24时'),
d5('5d', '5日'),
m1('1m', '1月'),
m3('3m', '3月'),
y1('1y', '1年');
const MarketPeriod(this.id, this.label);
final String id;
final String label;
}
enum ExchangeChannelKind { spot, bar, retail, td, recycle }
class MetalQuote {
const MetalQuote({
required this.metal,
required this.spotPrice,
required this.changeAmount,
required this.changePercent,
required this.updatedAt,
required this.sourceLabel,
required this.basisLabel,
});
final MetalType metal;
final double spotPrice;
final double changeAmount;
final double changePercent;
final DateTime updatedAt;
final String sourceLabel;
final String basisLabel;
}
class MarketPoint {
const MarketPoint({
required this.time,
required this.open,
required this.high,
required this.low,
required this.close,
});
final DateTime time;
final double open;
final double high;
final double low;
final double close;
}
class ExchangeChannel {
const ExchangeChannel({
required this.id,
required this.metal,
required this.name,
required this.kind,
required this.pricePerGram,
required this.source,
required this.updatedAt,
required this.hint,
});
final String id;
final MetalType metal;
final String name;
final ExchangeChannelKind kind;
final double pricePerGram;
final String source;
final DateTime updatedAt;
final String hint;
}
@@ -0,0 +1,216 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../common/domain/metal_type.dart';
import 'market_models.dart';
final mockMarketRepositoryProvider = Provider<MockMarketRepository>(
(ref) => MockMarketRepository(),
);
class MockMarketRepository {
MockMarketRepository();
final DateTime _updatedAt = DateTime(2026, 6, 18, 16, 11);
List<MetalQuote> getQuotes() {
return [
MetalQuote(
metal: MetalType.gold,
spotPrice: 943.05,
changeAmount: 8.12,
changePercent: 0.87,
updatedAt: _updatedAt,
sourceLabel: '行情聚合',
basisLabel: '上海金现货参考价',
),
MetalQuote(
metal: MetalType.platinum,
spotPrice: 384.20,
changeAmount: -2.46,
changePercent: -0.64,
updatedAt: _updatedAt,
sourceLabel: '行情聚合',
basisLabel: '国际盘换算参考价',
),
MetalQuote(
metal: MetalType.silver,
spotPrice: 15.75,
changeAmount: 0.18,
changePercent: 1.16,
updatedAt: _updatedAt,
sourceLabel: '行情聚合',
basisLabel: '上海银现货参考价',
),
];
}
MetalQuote quoteFor(MetalType metal) =>
getQuotes().firstWhere((quote) => quote.metal == metal);
List<MarketPoint> chartFor(MetalType metal, MarketPeriod period) {
final quote = quoteFor(metal);
final points = <MarketPoint>[];
final step = switch (period) {
MarketPeriod.h24 => const Duration(hours: 2),
MarketPeriod.d5 => const Duration(days: 1),
MarketPeriod.m1 => const Duration(days: 3),
MarketPeriod.m3 => const Duration(days: 9),
MarketPeriod.y1 => const Duration(days: 30),
};
for (var i = 11; i >= 0; i--) {
final drift = (i - 5.5) * quote.changeAmount / 10;
final close = quote.spotPrice - drift;
points.add(
MarketPoint(
time: _updatedAt.subtract(step * i),
open: close - quote.changeAmount / 20,
high: close + quote.spotPrice * 0.003,
low: close - quote.spotPrice * 0.003,
close: close,
),
);
}
return points;
}
List<ExchangeChannel> channelsFor(MetalType metal, {double recycle = 0.97}) {
final quote = quoteFor(metal);
return switch (metal) {
MetalType.gold => [
_channel(
'gold_spot',
metal,
'上海金现货',
ExchangeChannelKind.spot,
943.05,
'上海金现货',
'材料价值口径 · 非金店零售 / 回收价',
),
_channel(
'gold_bar',
metal,
'投资金条',
ExchangeChannelKind.bar,
955.00,
'银行 / 品牌金条参考',
'投资金条参考 · 通常含少量升水',
),
_channel(
'gold_ctf',
metal,
'周大福金店',
ExchangeChannelKind.retail,
1238.00,
'品牌零售挂牌参考',
'含工费与品牌溢价 · 以门店为准',
),
_channel(
'gold_lfx',
metal,
'老凤祥金店',
ExchangeChannelKind.retail,
1248.00,
'品牌零售挂牌参考',
'含工费与品牌溢价 · 以门店为准',
),
_channel(
'gold_td',
metal,
'黄金 T+D',
ExchangeChannelKind.td,
919.50,
'SGE 递延参考',
'交易品种参考 · 非实物购买价',
),
_channel(
'gold_recycle',
metal,
'回收参考',
ExchangeChannelKind.recycle,
quote.spotPrice * recycle,
'材料价值 × 回收折扣',
'实际以门店检测为准',
),
],
MetalType.platinum => [
_channel(
'platinum_spot',
metal,
'铂金现货',
ExchangeChannelKind.spot,
384.20,
'国际盘换算参考',
'材料价值口径 · 非饰品零售价',
),
_channel(
'platinum_retail',
metal,
'铂金饰品',
ExchangeChannelKind.retail,
498.00,
'零售挂牌参考',
'含工费与品牌溢价',
),
_channel(
'platinum_recycle',
metal,
'回收参考',
ExchangeChannelKind.recycle,
quote.spotPrice * recycle,
'材料价值 × 回收折扣',
'实际以门店检测为准',
),
],
MetalType.silver => [
_channel(
'silver_spot',
metal,
'白银现货',
ExchangeChannelKind.spot,
15.75,
'上海银现货',
'材料价值口径 · 非饰品零售价',
),
_channel(
'silver_retail',
metal,
'白银饰品',
ExchangeChannelKind.retail,
42.00,
'零售挂牌参考',
'含工费与品牌溢价',
),
_channel(
'silver_recycle',
metal,
'回收参考',
ExchangeChannelKind.recycle,
quote.spotPrice * recycle,
'材料价值 × 回收折扣',
'实际以门店检测为准',
),
],
};
}
ExchangeChannel _channel(
String id,
MetalType metal,
String name,
ExchangeChannelKind kind,
double price,
String source,
String hint,
) {
return ExchangeChannel(
id: id,
metal: metal,
name: name,
kind: kind,
pricePerGram: price,
source: source,
updatedAt: _updatedAt,
hint: hint,
);
}
}
@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/domain/metal_type.dart';
import '../../../common/domain/money.dart';
import '../../../common/widgets/adaptive.dart';
import '../application/exchange_calculator_controller.dart';
import '../application/market_controller.dart';
import '../data/market_models.dart';
class MarketPage extends ConsumerWidget {
const MarketPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final market = ref.watch(marketControllerProvider);
final exchange = ref.watch(exchangeCalculatorControllerProvider);
final cs = ShadTheme.of(context).colorScheme;
return SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Adaptive.contentMaxWidth,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SegmentedButton<MetalType>(
segments: [
for (final metal in MetalType.values)
ButtonSegment(value: metal, label: Text(metal.label)),
],
selected: {market.selectedMetal},
onSelectionChanged: (next) {
final metal = next.first;
ref
.read(marketControllerProvider.notifier)
.selectMetal(metal);
ref
.read(exchangeCalculatorControllerProvider.notifier)
.selectMetal(metal);
},
),
const SizedBox(height: 12),
_Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(market.quote.basisLabel, style: _mutedStyle(cs)),
const SizedBox(height: 8),
Text(
'${formatCny(market.quote.spotPrice)} / 克',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.w800,
color: cs.foreground,
),
),
const SizedBox(height: 6),
Text(
'${market.quote.changeAmount >= 0 ? '+' : ''}${market.quote.changeAmount.toStringAsFixed(2)} · ${market.quote.changePercent.toStringAsFixed(2)}%',
style: TextStyle(
color: market.quote.changeAmount >= 0
? const Color(0xFFC0392B)
: const Color(0xFF2E8B6F),
),
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
children: [
for (final period in MarketPeriod.values)
ChoiceChip(
label: Text(period.label),
selected: period == market.period,
onSelected: (_) => ref
.read(marketControllerProvider.notifier)
.selectPeriod(period),
),
],
),
const SizedBox(height: 14),
Text(
'走势图 mock 点位:${market.points.length}',
style: _mutedStyle(cs),
),
],
),
),
const SizedBox(height: 12),
_Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'兑换试算',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 10),
Text(
'${formatGram(exchange.gram)} ${exchange.metal.label} = ${formatCny(exchange.amount)}',
style: TextStyle(fontSize: 20, color: cs.foreground),
),
const SizedBox(height: 8),
Text(
'1 克 ${exchange.metal.label} = ${formatCny(exchange.selectedChannel.pricePerGram)} · ${exchange.selectedChannel.source}',
style: _mutedStyle(cs),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final channel in exchange.channels)
ChoiceChip(
label: Text(channel.name),
selected:
channel.id == exchange.selectedChannel.id,
onSelected: (_) => ref
.read(
exchangeCalculatorControllerProvider
.notifier,
)
.selectChannel(channel.id),
),
],
),
const SizedBox(height: 8),
Text(
exchange.selectedChannel.hint,
style: _mutedStyle(cs),
),
],
),
),
],
),
),
),
],
),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(12),
),
child: Padding(padding: const EdgeInsets.all(16), child: child),
);
}
}
TextStyle _mutedStyle(ShadColorScheme cs) {
return TextStyle(fontSize: 13, color: cs.mutedForeground, letterSpacing: 0);
}
@@ -0,0 +1,34 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../data/mock_news_repository.dart';
import '../data/news_models.dart';
final newsControllerProvider = StateNotifierProvider<NewsController, NewsState>(
(ref) => NewsController(ref.watch(mockNewsRepositoryProvider)),
);
class NewsState {
const NewsState({required this.items, required this.refreshedAt});
final List<NewsItem> items;
final DateTime refreshedAt;
}
class NewsController extends StateNotifier<NewsState> {
NewsController(this._repository)
: super(
NewsState(
items: _repository.loadFeed(),
refreshedAt: DateTime(2026, 6, 18, 16, 11),
),
);
final MockNewsRepository _repository;
void refresh() {
state = NewsState(
items: _repository.loadFeed(),
refreshedAt: DateTime.now(),
);
}
}
@@ -0,0 +1,53 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../common/domain/metal_type.dart';
import 'news_models.dart';
final mockNewsRepositoryProvider = Provider<MockNewsRepository>(
(ref) => MockNewsRepository(),
);
class MockNewsRepository {
List<NewsItem> loadFeed() {
final base = DateTime(2026, 6, 18, 16, 11);
final items = [
NewsItem(
id: 'news_gold_001',
metal: MetalType.gold,
title: '金价维持高位震荡,实物金溢价继续分化',
source: '金值整理',
publishedAt: base.subtract(const Duration(minutes: 18)),
summary: '现货价与品牌零售价之间仍有明显价差,持仓估值应优先看材料价值。',
body: const [
'今日黄金现货价格维持高位震荡,品牌金店挂牌价与现货材料价值之间仍有明显差距。',
'对持有者而言,材料价值更适合用于日常估值;对购买者而言,需要额外关注工费、品牌溢价与回收折扣。',
],
),
NewsItem(
id: 'news_silver_001',
metal: MetalType.silver,
title: '白银跟随工业金属情绪回暖,短线波动放大',
source: '市场简报',
publishedAt: base.subtract(const Duration(hours: 1, minutes: 4)),
summary: '银价弹性较强,饰品材料价值和零售购买价差异更大。',
body: const [
'白银价格今日跟随工业金属情绪回暖,短线波动较黄金更明显。',
'银饰通常包含较高加工与零售成本,材料价值占比可能低于用户直觉。',
],
),
NewsItem(
id: 'news_platinum_001',
metal: MetalType.platinum,
title: '铂金回收报价偏谨慎,饰品估值需看纯度',
source: '金属观察',
publishedAt: base.subtract(const Duration(hours: 2, minutes: 36)),
summary: 'PT950 饰品估值建议按克重、纯度和回收折扣分层查看。',
body: const [
'铂金饰品回收报价通常更依赖门店检测与成色确认,报价口径比现货价格更谨慎。',
'记录持仓时建议保留纯度、克重、购买渠道和成本信息,便于后续估值与盈亏回看。',
],
),
];
return [...items]..sort((a, b) => b.publishedAt.compareTo(a.publishedAt));
}
}
+21
View File
@@ -0,0 +1,21 @@
import '../../../common/domain/metal_type.dart';
class NewsItem {
const NewsItem({
required this.id,
required this.metal,
required this.title,
required this.source,
required this.publishedAt,
required this.summary,
required this.body,
});
final String id;
final MetalType metal;
final String title;
final String source;
final DateTime publishedAt;
final String summary;
final List<String> body;
}
@@ -0,0 +1,122 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/widgets/adaptive.dart';
import '../application/news_controller.dart';
class NewsPage extends ConsumerWidget {
const NewsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final news = ref.watch(newsControllerProvider);
final cs = ShadTheme.of(context).colorScheme;
return SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Adaptive.contentMaxWidth,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Text(
'信息整理 · 非投资建议',
style: TextStyle(
color: cs.mutedForeground,
fontSize: 13,
),
),
),
TextButton(
onPressed: () =>
ref.read(newsControllerProvider.notifier).refresh(),
child: const Text('刷新'),
),
],
),
const SizedBox(height: 8),
for (final item in news.items)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: DecoratedBox(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: item.metal.color.withValues(
alpha: 0.12,
),
borderRadius: BorderRadius.circular(999),
),
child: Text(
item.metal.shortLabel,
style: TextStyle(
color: item.metal.color,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 8),
Text(
'${item.source} · ${item.publishedAt.hour.toString().padLeft(2, '0')}:${item.publishedAt.minute.toString().padLeft(2, '0')}',
style: TextStyle(
color: cs.mutedForeground,
fontSize: 12,
),
),
],
),
const SizedBox(height: 10),
Text(
item.title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 8),
Text(
item.summary,
style: TextStyle(
color: cs.mutedForeground,
height: 1.5,
),
),
],
),
),
),
),
],
),
),
),
],
),
);
}
}
@@ -0,0 +1,43 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../../common/domain/metal_type.dart';
import '../data/profile_models.dart';
final profileSettingsControllerProvider =
StateNotifierProvider<ProfileSettingsController, ProfileSettings>(
(ref) => ProfileSettingsController(),
);
class ProfileSettingsController extends StateNotifier<ProfileSettings> {
ProfileSettingsController()
: super(
const ProfileSettings(
amountHidden: false,
recycleDiscounts: {
MetalType.gold: 0.97,
MetalType.platinum: 0.93,
MetalType.silver: 0.88,
},
purityPresets: {
'足金9999': 0.9999,
'足金999': 0.999,
'PT950': 0.95,
'999银': 0.999,
},
priceColorMode: PriceColorMode.redUpGreenDown,
mockLoggedIn: false,
),
);
void toggleAmountHidden() {
state = state.copyWith(amountHidden: !state.amountHidden);
}
void setMockLoggedIn(bool value) {
state = state.copyWith(mockLoggedIn: value);
}
void setPriceColorMode(PriceColorMode mode) {
state = state.copyWith(priceColorMode: mode);
}
}
@@ -0,0 +1,42 @@
import '../../../common/domain/metal_type.dart';
enum PriceColorMode {
redUpGreenDown('红涨绿跌'),
greenUpRedDown('绿涨红跌');
const PriceColorMode(this.label);
final String label;
}
class ProfileSettings {
const ProfileSettings({
required this.amountHidden,
required this.recycleDiscounts,
required this.purityPresets,
required this.priceColorMode,
required this.mockLoggedIn,
});
final bool amountHidden;
final Map<MetalType, double> recycleDiscounts;
final Map<String, double> purityPresets;
final PriceColorMode priceColorMode;
final bool mockLoggedIn;
ProfileSettings copyWith({
bool? amountHidden,
Map<MetalType, double>? recycleDiscounts,
Map<String, double>? purityPresets,
PriceColorMode? priceColorMode,
bool? mockLoggedIn,
}) {
return ProfileSettings(
amountHidden: amountHidden ?? this.amountHidden,
recycleDiscounts: recycleDiscounts ?? this.recycleDiscounts,
purityPresets: purityPresets ?? this.purityPresets,
priceColorMode: priceColorMode ?? this.priceColorMode,
mockLoggedIn: mockLoggedIn ?? this.mockLoggedIn,
);
}
}
@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../common/router/app_router.dart';
import '../../../common/widgets/adaptive.dart';
import '../../auth/application/auth_controller.dart';
import '../../auth/data/auth_models.dart';
import '../../profile/application/profile_settings_controller.dart';
class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(profileSettingsControllerProvider);
final auth = ref.watch(authProvider);
final cs = ShadTheme.of(context).colorScheme;
final loggedIn = auth.valueOrNull is LoggedInAuthState;
final currentUser = auth.valueOrNull is LoggedInAuthState
? (auth.valueOrNull as LoggedInAuthState).user
: null;
return SafeArea(
top: false,
child: ListView(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: Adaptive.contentMaxWidth,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Card(
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentUser?.nickname ?? '未登录',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 6),
Text(
currentUser?.phone ?? '登录后可使用本地同步、云备份占位和账户管理。',
style: TextStyle(color: cs.mutedForeground),
),
],
),
),
FilledButton(
onPressed: () => context.push(AppRoutes.settings),
child: const Text('设置'),
),
],
),
),
const SizedBox(height: 12),
_Card(
child: Column(
children: [
_SettingRow(
label: '隐藏金额',
value: settings.amountHidden ? '已隐藏' : '显示中',
trailing: Switch(
value: settings.amountHidden,
onChanged: (_) => ref
.read(
profileSettingsControllerProvider.notifier,
)
.toggleAmountHidden(),
),
),
_SettingRow(
label: '回收折扣',
value:
'${(settings.recycleDiscounts.values.first * 100).toStringAsFixed(0)}%',
),
_SettingRow(
label: '涨跌颜色',
value: settings.priceColorMode.label,
),
_SettingRow(
label: '纯度系数',
value: '${settings.purityPresets.length}',
),
],
),
),
const SizedBox(height: 12),
_Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
FilledButton.tonal(
onPressed: loggedIn
? () => context.push(AppRoutes.login)
: () => context.push(AppRoutes.login),
child: Text(loggedIn ? '切换账号' : '去登录'),
),
const SizedBox(height: 10),
OutlinedButton(
onPressed: () => context.push(AppRoutes.settings),
child: const Text('打开设置'),
),
],
),
),
const SizedBox(height: 12),
_Card(
child: Text(
'风险提示 · 数据来源 · 免责\n价格、资讯与估值均为本地 mock 数据,仅用于产品原型验证,不构成投资建议。',
style: TextStyle(
color: cs.mutedForeground,
height: 1.6,
fontSize: 13,
),
),
),
],
),
),
),
],
),
);
}
}
class _SettingRow extends StatelessWidget {
const _SettingRow({required this.label, required this.value, this.trailing});
final String label;
final String value;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(fontSize: 15, color: cs.foreground),
),
),
Text(value, style: TextStyle(color: cs.mutedForeground)),
if (trailing != null) ...[const SizedBox(width: 8), trailing!],
],
),
);
}
}
class _Card extends StatelessWidget {
const _Card({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(12),
),
child: Padding(padding: const EdgeInsets.all(16), child: child),
);
}
}
@@ -0,0 +1,476 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:video_player/video_player.dart';
import '../../../common/config/app_h5_urls.dart';
import '../../../common/services/device_header_service.dart';
import '../../../common/theme/jinzhi_theme.dart';
import '../../../common/widgets/adaptive.dart';
import '../../../common/router/app_router.dart';
class PrivacyConsentPage extends StatefulWidget {
const PrivacyConsentPage({super.key});
@override
State<PrivacyConsentPage> createState() => _PrivacyConsentPageState();
}
class _PrivacyConsentPageState extends State<PrivacyConsentPage> {
bool _dialogShown = false;
late final VideoPlayerController _video;
bool _videoReady = false;
@override
void initState() {
super.initState();
_video = VideoPlayerController.asset('assets/launch/launch-bg.mp4')
..setLooping(true)
..setVolume(0)
..initialize().then((_) {
if (!mounted) return;
_video.setPlaybackSpeed(0.5);
_video.play();
setState(() => _videoReady = true);
});
}
@override
void dispose() {
_video.dispose();
super.dispose();
}
Future<void> _agree() async {
await DeviceHeaderService.to.markPrivacyAgreed();
await DeviceHeaderService.to.ensureInitialized();
if (mounted) context.go(AppRoutes.assets);
}
Future<void> _enter() async {
await DeviceHeaderService.to.markIosStartupSeen();
if (mounted) context.go(AppRoutes.assets);
}
@override
Widget build(BuildContext context) {
const cs = jinzhiDarkScheme;
final showAndroidConsent = DeviceHeaderService.to.privacyConsentRequired;
if (showAndroidConsent && !_dialogShown) {
_dialogShown = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
isDismissible: false,
enableDrag: false,
backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.55),
builder: (_) => _PrivacyConsentSheet(onAgree: _agree),
);
});
}
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Scaffold(
backgroundColor: cs.background,
body: Stack(
fit: StackFit.expand,
children: [
if (_videoReady)
FittedBox(
fit: BoxFit.cover,
clipBehavior: Clip.hardEdge,
child: SizedBox(
width: _video.value.size.width,
height: _video.value.size.height,
child: VideoPlayer(_video),
),
)
else
Image.asset('assets/launch/launch-poster.jpg', fit: BoxFit.cover),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0.0, 0.46, 0.88],
colors: [
cs.background.withValues(alpha: 0.66),
cs.background.withValues(alpha: 0.82),
cs.background,
],
),
),
),
SafeArea(
child: Column(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const _LaunchMark(size: 88),
const SizedBox(height: 26),
Text(
'研听',
style: TextStyle(
fontSize: 42,
fontWeight: FontWeight.w800,
color: cs.foreground,
height: 1.1,
),
),
const SizedBox(height: 12),
Text(
'读懂全球研报',
style: TextStyle(
fontSize: 16,
color: cs.foreground.withValues(alpha: 0.82),
letterSpacing: 2,
),
),
],
),
),
if (!showAndroidConsent)
Padding(
padding: const EdgeInsets.fromLTRB(22, 0, 22, 20),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: Adaptive.isTablet(context)
? 400
: double.infinity,
),
child: GestureDetector(
onTap: _enter,
behavior: HitTestBehavior.opaque,
child: Container(
width: double.infinity,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: cs.primary,
borderRadius: BorderRadius.circular(9999),
),
child: Text(
'立即进入',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: cs.primaryForeground,
),
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Text(
'内容为公开研报的结构化解读,不构成投资建议',
style: TextStyle(
fontSize: 11.5,
color: cs.mutedForeground.withValues(alpha: 0.75),
),
),
),
],
),
),
],
),
),
);
}
}
class _LaunchMark extends StatefulWidget {
const _LaunchMark({this.size = 88});
final double size;
@override
State<_LaunchMark> createState() => _LaunchMarkState();
}
class _LaunchMarkState extends State<_LaunchMark>
with SingleTickerProviderStateMixin {
late final AnimationController _spin = AnimationController(
vsync: this,
duration: const Duration(seconds: 8),
)..repeat();
@override
void dispose() {
_spin.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final m = widget.size;
const cxf = 0.6758, cyf = 0.3242, sideF = 0.4121;
final side = m * sideF;
return SizedBox(
width: m,
height: m,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned.fill(
child: Image.asset('assets/launch/mark-grid-lime.png'),
),
Positioned(
left: cxf * m - side / 2,
top: cyf * m - side / 2,
width: side,
height: side,
child: RotationTransition(
turns: _spin,
child: Image.asset('assets/launch/mark-star-lime.png'),
),
),
],
),
);
}
}
class _PrivacyConsentSheet extends StatefulWidget {
const _PrivacyConsentSheet({required this.onAgree});
final Future<void> Function() onAgree;
@override
State<_PrivacyConsentSheet> createState() => _PrivacyConsentSheetState();
}
class _PrivacyConsentSheetState extends State<_PrivacyConsentSheet> {
bool _verify = false;
@override
Widget build(BuildContext context) {
const cs = jinzhiDarkScheme;
final privacyUrl = AppH5UrlsHelper.buildUrlWithNight(
AppH5Urls.privacyUrl,
false,
);
final protocolUrl = AppH5UrlsHelper.buildUrlWithNight(
AppH5Urls.userProtocolUrl,
false,
);
return SafeArea(
top: false,
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: cs.card,
borderRadius: const BorderRadius.vertical(top: Radius.circular(11.2)),
border: Border.all(color: cs.border),
),
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
child: _verify
? _verifyView(cs, protocolUrl, privacyUrl)
: _consentView(cs, protocolUrl, privacyUrl),
),
);
}
Widget _grip(ShadColorScheme cs) => Center(
child: Container(
width: 38,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: cs.border,
borderRadius: BorderRadius.circular(9999),
),
),
);
Widget _consentView(
ShadColorScheme cs,
String protocolUrl,
String privacyUrl,
) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_grip(cs),
Text(
'欢迎使用研听',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 12),
_policyBody(cs, protocolUrl: protocolUrl, privacyUrl: privacyUrl),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _Btn(
label: '不同意',
primary: false,
cs: cs,
onTap: () => setState(() => _verify = true),
),
),
const SizedBox(width: 12),
Expanded(
child: _Btn(
label: '同意',
primary: true,
cs: cs,
onTap: () async {
Navigator.of(context).pop();
await widget.onAgree();
},
),
),
],
),
],
);
}
Widget _verifyView(
ShadColorScheme cs,
String protocolUrl,
String privacyUrl,
) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_grip(cs),
Text(
'确认放弃使用?',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: cs.foreground,
),
),
const SizedBox(height: 12),
_policyBody(cs, protocolUrl: protocolUrl, privacyUrl: privacyUrl),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _Btn(
label: '不同意并退出',
primary: false,
cs: cs,
onTap: () => SystemNavigator.pop(),
),
),
const SizedBox(width: 12),
Expanded(
child: _Btn(
label: '同意',
primary: true,
cs: cs,
onTap: () async {
Navigator.of(context).pop();
await widget.onAgree();
},
),
),
],
),
],
);
}
Widget _policyBody(
ShadColorScheme cs, {
required String protocolUrl,
required String privacyUrl,
}) {
return Text.rich(
TextSpan(
style: TextStyle(fontSize: 13, height: 1.7, color: cs.mutedForeground),
children: [
const TextSpan(
text:
'尊敬的用户:\n\n我们非常重视您的个人信息和隐私保护,为了更好的保障您的个人权益,请您在使用我们的产品前,仔细阅读并充分理解 ',
),
_link('《用户协议》', protocolUrl, cs),
const TextSpan(text: ''),
_link('《隐私政策》', privacyUrl, cs),
const TextSpan(
text:
'内容。我们将按照该协议内容收集、使用和共享您的个人信息。\n为了保证业务安全风控,在您使用我们基本功能的过程中,我们会收集您的手机号码,以及硬件序列号或唯一设备识别码(如 AndroidID/MAC/OAID/IMEI/WIFI 的 BSSID)等信息。\n如您同意,请点击“同意”开始接受我们的服务。',
),
],
),
);
}
TextSpan _link(String text, String url, ShadColorScheme cs) => TextSpan(
text: text,
style: TextStyle(
color: cs.accentForeground,
fontWeight: FontWeight.w500,
decoration: TextDecoration.underline,
decorationColor: cs.accentForeground,
),
recognizer: TapGestureRecognizer()
..onTap = () => launchUrlString(
AppH5UrlsHelper.withCacheBuster(url),
mode: LaunchMode.inAppBrowserView,
),
);
}
class _Btn extends StatelessWidget {
const _Btn({
required this.label,
required this.primary,
required this.cs,
required this.onTap,
});
final String label;
final bool primary;
final ShadColorScheme cs;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
color: primary ? cs.primary : cs.secondary,
borderRadius: BorderRadius.circular(12),
border: primary ? null : Border.all(color: cs.border),
),
child: Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: primary ? cs.primaryForeground : cs.foreground,
),
),
),
);
}
}
File diff suppressed because it is too large Load Diff