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