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