feat:参照研听的基础工程
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user