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