380 lines
12 KiB
Dart
380 lines
12 KiB
Dart
|
|
import 'package:flutter/material.dart';
|
||
|
|
import 'package:provider/provider.dart';
|
||
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||
|
|
import '../../../core/theme/app_theme.dart';
|
||
|
|
import '../../../core/theme/app_color_scheme.dart';
|
||
|
|
import '../../../core/theme/app_spacing.dart';
|
||
|
|
import '../../../data/services/asset_service.dart';
|
||
|
|
|
||
|
|
/// 盈亏分析页面 - 月度盈亏日历
|
||
|
|
class ProfitAnalysisPage extends StatefulWidget {
|
||
|
|
const ProfitAnalysisPage({super.key});
|
||
|
|
|
||
|
|
@override
|
||
|
|
State<ProfitAnalysisPage> createState() => _ProfitAnalysisPageState();
|
||
|
|
}
|
||
|
|
|
||
|
|
class _ProfitAnalysisPageState extends State<ProfitAnalysisPage> {
|
||
|
|
late DateTime _currentMonth;
|
||
|
|
Map<String, dynamic>? _profitData;
|
||
|
|
bool _isLoading = false;
|
||
|
|
|
||
|
|
@override
|
||
|
|
void initState() {
|
||
|
|
super.initState();
|
||
|
|
_currentMonth = DateTime.now();
|
||
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _loadProfit());
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 主题感知颜色
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
bool get _isDark => Theme.of(context).brightness == Brightness.dark;
|
||
|
|
|
||
|
|
Color get _upColor => AppColorScheme.getUpColor(_isDark);
|
||
|
|
Color get _downColor => AppColorScheme.getDownColor(_isDark);
|
||
|
|
|
||
|
|
Color get _scaffoldBg =>
|
||
|
|
_isDark ? AppColorScheme.darkBackground : AppColorScheme.lightBackground;
|
||
|
|
|
||
|
|
Color get _cardBg => _isDark
|
||
|
|
? AppColorScheme.darkSurfaceContainer
|
||
|
|
: AppColorScheme.lightSurfaceLowest;
|
||
|
|
|
||
|
|
Color get _cardBorder => _isDark
|
||
|
|
? AppColorScheme.darkOutlineVariant.withValues(alpha: 0.15)
|
||
|
|
: AppColorScheme.lightOutlineVariant.withValues(alpha: 0.5);
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 数据加载
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
Future<void> _loadProfit() async {
|
||
|
|
setState(() => _isLoading = true);
|
||
|
|
try {
|
||
|
|
final assetService = context.read<AssetService>();
|
||
|
|
final response = await assetService.getDailyProfit(
|
||
|
|
year: _currentMonth.year,
|
||
|
|
month: _currentMonth.month,
|
||
|
|
);
|
||
|
|
if (mounted) {
|
||
|
|
setState(() {
|
||
|
|
_profitData = response.data;
|
||
|
|
_isLoading = false;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} catch (_) {
|
||
|
|
if (mounted) setState(() => _isLoading = false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
void _previousMonth() {
|
||
|
|
setState(() {
|
||
|
|
_currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1);
|
||
|
|
});
|
||
|
|
_loadProfit();
|
||
|
|
}
|
||
|
|
|
||
|
|
void _nextMonth() {
|
||
|
|
setState(() {
|
||
|
|
_currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1);
|
||
|
|
});
|
||
|
|
_loadProfit();
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 盈亏数据解析
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
double? _getDayProfit(int day) {
|
||
|
|
if (_profitData == null) return null;
|
||
|
|
final daily = _profitData!['daily'] as Map<String, dynamic>?;
|
||
|
|
if (daily == null) return null;
|
||
|
|
final dateStr =
|
||
|
|
'${_currentMonth.year}-${_currentMonth.month.toString().padLeft(2, '0')}-${day.toString().padLeft(2, '0')}';
|
||
|
|
final value = daily[dateStr];
|
||
|
|
if (value == null) return null;
|
||
|
|
return (value is num) ? value.toDouble() : double.tryParse(value.toString());
|
||
|
|
}
|
||
|
|
|
||
|
|
double get _monthProfit {
|
||
|
|
if (_profitData == null) return 0;
|
||
|
|
final value = _profitData!['totalProfit'];
|
||
|
|
if (value == null) return 0;
|
||
|
|
return (value is num) ? value.toDouble() : double.tryParse(value.toString()) ?? 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ============================================
|
||
|
|
// 构建 UI
|
||
|
|
// ============================================
|
||
|
|
|
||
|
|
@override
|
||
|
|
Widget build(BuildContext context) {
|
||
|
|
final colorScheme = Theme.of(context).colorScheme;
|
||
|
|
final now = DateTime.now();
|
||
|
|
final isCurrentMonth =
|
||
|
|
_currentMonth.year == now.year && _currentMonth.month == now.month;
|
||
|
|
|
||
|
|
return Scaffold(
|
||
|
|
backgroundColor: _scaffoldBg,
|
||
|
|
appBar: AppBar(
|
||
|
|
title: const Text('盈亏分析'),
|
||
|
|
backgroundColor: _scaffoldBg,
|
||
|
|
elevation: 0,
|
||
|
|
scrolledUnderElevation: 0,
|
||
|
|
centerTitle: true,
|
||
|
|
),
|
||
|
|
body: SingleChildScrollView(
|
||
|
|
padding: EdgeInsets.all(AppSpacing.lg),
|
||
|
|
child: Container(
|
||
|
|
padding: EdgeInsets.all(AppSpacing.lg),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: _cardBg,
|
||
|
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
||
|
|
border: Border.all(color: _cardBorder),
|
||
|
|
),
|
||
|
|
child: Column(
|
||
|
|
mainAxisSize: MainAxisSize.min,
|
||
|
|
children: [
|
||
|
|
// 月度盈亏摘要
|
||
|
|
_buildSummarySection(colorScheme),
|
||
|
|
SizedBox(height: AppSpacing.md),
|
||
|
|
|
||
|
|
// 月份导航
|
||
|
|
_buildMonthNavigation(colorScheme, isCurrentMonth),
|
||
|
|
SizedBox(height: AppSpacing.sm),
|
||
|
|
|
||
|
|
// 星期标题
|
||
|
|
_buildWeekdayHeaders(colorScheme),
|
||
|
|
SizedBox(height: AppSpacing.xs),
|
||
|
|
|
||
|
|
// 日历网格
|
||
|
|
if (_isLoading)
|
||
|
|
_buildLoadingIndicator(colorScheme)
|
||
|
|
else
|
||
|
|
..._buildCalendarGrid(
|
||
|
|
colorScheme,
|
||
|
|
now,
|
||
|
|
isCurrentMonth,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 月度盈亏摘要
|
||
|
|
Widget _buildSummarySection(ColorScheme colorScheme) {
|
||
|
|
final isProfit = _monthProfit >= 0;
|
||
|
|
final color = _isLoading ? colorScheme.onSurfaceVariant : (isProfit ? _upColor : _downColor);
|
||
|
|
|
||
|
|
return Column(
|
||
|
|
children: [
|
||
|
|
Text(
|
||
|
|
'月度盈亏',
|
||
|
|
style: AppTextStyles.bodyMedium(context).copyWith(
|
||
|
|
color: colorScheme.onSurfaceVariant,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
SizedBox(height: AppSpacing.xs),
|
||
|
|
Text(
|
||
|
|
_isLoading
|
||
|
|
? '--'
|
||
|
|
: '${isProfit ? '+' : ''}${_monthProfit.toStringAsFixed(2)} USDT',
|
||
|
|
style: AppTextStyles.displaySmall(context).copyWith(
|
||
|
|
fontWeight: FontWeight.bold,
|
||
|
|
color: color,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 月份导航行
|
||
|
|
Widget _buildMonthNavigation(ColorScheme colorScheme, bool isCurrentMonth) {
|
||
|
|
return Row(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
|
|
children: [
|
||
|
|
// 上一月
|
||
|
|
GestureDetector(
|
||
|
|
onTap: _previousMonth,
|
||
|
|
child: Container(
|
||
|
|
padding: EdgeInsets.all(AppSpacing.xs + 1),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: colorScheme.surfaceContainerHigh,
|
||
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||
|
|
),
|
||
|
|
child: Icon(
|
||
|
|
LucideIcons.chevronLeft,
|
||
|
|
size: 16,
|
||
|
|
color: colorScheme.onSurfaceVariant,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
// 当前年月
|
||
|
|
Text(
|
||
|
|
'${_currentMonth.year}年${_currentMonth.month}月',
|
||
|
|
style: AppTextStyles.headlineMedium(context).copyWith(
|
||
|
|
fontWeight: FontWeight.bold,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
// 下一月(当前月禁用)
|
||
|
|
GestureDetector(
|
||
|
|
onTap: isCurrentMonth ? null : _nextMonth,
|
||
|
|
child: Container(
|
||
|
|
padding: EdgeInsets.all(AppSpacing.xs + 1),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: isCurrentMonth
|
||
|
|
? colorScheme.surfaceContainerHigh.withValues(alpha: 0.5)
|
||
|
|
: colorScheme.surfaceContainerHigh,
|
||
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||
|
|
),
|
||
|
|
child: Icon(
|
||
|
|
LucideIcons.chevronRight,
|
||
|
|
size: 16,
|
||
|
|
color: isCurrentMonth
|
||
|
|
? colorScheme.onSurfaceVariant.withValues(alpha: 0.4)
|
||
|
|
: colorScheme.onSurfaceVariant,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
],
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 星期标题行
|
||
|
|
Widget _buildWeekdayHeaders(ColorScheme colorScheme) {
|
||
|
|
return Row(
|
||
|
|
children: ['一', '二', '三', '四', '五', '六', '日'].map((d) {
|
||
|
|
return Expanded(
|
||
|
|
child: Center(
|
||
|
|
child: Text(
|
||
|
|
d,
|
||
|
|
style: AppTextStyles.bodySmall(context).copyWith(
|
||
|
|
fontWeight: FontWeight.w500,
|
||
|
|
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}).toList(),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 加载指示器
|
||
|
|
Widget _buildLoadingIndicator(ColorScheme colorScheme) {
|
||
|
|
return Padding(
|
||
|
|
padding: EdgeInsets.symmetric(vertical: AppSpacing.xxl),
|
||
|
|
child: Center(
|
||
|
|
child: SizedBox(
|
||
|
|
width: 20,
|
||
|
|
height: 20,
|
||
|
|
child: CircularProgressIndicator(
|
||
|
|
strokeWidth: 2,
|
||
|
|
color: colorScheme.primary,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 日历网格
|
||
|
|
List<Widget> _buildCalendarGrid(
|
||
|
|
ColorScheme colorScheme,
|
||
|
|
DateTime now,
|
||
|
|
bool isCurrentMonth,
|
||
|
|
) {
|
||
|
|
final firstDayOfMonth = DateTime(_currentMonth.year, _currentMonth.month, 1);
|
||
|
|
final daysInMonth =
|
||
|
|
DateTime(_currentMonth.year, _currentMonth.month + 1, 0).day;
|
||
|
|
final startWeekday = firstDayOfMonth.weekday;
|
||
|
|
|
||
|
|
final List<Widget> rows = [];
|
||
|
|
List<Widget> cells = [];
|
||
|
|
|
||
|
|
// 填充月初空白
|
||
|
|
for (int i = 1; i < startWeekday; i++) {
|
||
|
|
cells.add(const Expanded(child: SizedBox.shrink()));
|
||
|
|
}
|
||
|
|
|
||
|
|
// 填充每天
|
||
|
|
for (int day = 1; day <= daysInMonth; day++) {
|
||
|
|
final profit = _getDayProfit(day);
|
||
|
|
final isToday = isCurrentMonth && day == now.day;
|
||
|
|
final hasProfit = profit != null && profit != 0;
|
||
|
|
|
||
|
|
cells.add(
|
||
|
|
Expanded(
|
||
|
|
child: AspectRatio(
|
||
|
|
aspectRatio: 1,
|
||
|
|
child: Container(
|
||
|
|
margin: EdgeInsets.all(1),
|
||
|
|
decoration: BoxDecoration(
|
||
|
|
color: isToday
|
||
|
|
? colorScheme.primary.withValues(alpha: 0.12)
|
||
|
|
: hasProfit
|
||
|
|
? (profit! > 0
|
||
|
|
? _upColor.withValues(alpha: 0.08)
|
||
|
|
: _downColor.withValues(alpha: 0.08))
|
||
|
|
: Colors.transparent,
|
||
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||
|
|
border: isToday
|
||
|
|
? Border.all(
|
||
|
|
color: colorScheme.primary.withValues(alpha: 0.4),
|
||
|
|
width: 1,
|
||
|
|
)
|
||
|
|
: null,
|
||
|
|
),
|
||
|
|
child: Column(
|
||
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
||
|
|
children: [
|
||
|
|
Text(
|
||
|
|
'$day',
|
||
|
|
style: AppTextStyles.bodySmall(context).copyWith(
|
||
|
|
fontSize: 10,
|
||
|
|
fontWeight: isToday ? FontWeight.bold : FontWeight.w400,
|
||
|
|
color: isToday
|
||
|
|
? colorScheme.primary
|
||
|
|
: colorScheme.onSurface,
|
||
|
|
),
|
||
|
|
),
|
||
|
|
if (hasProfit) ...[
|
||
|
|
SizedBox(height: 1),
|
||
|
|
Text(
|
||
|
|
'${profit! > 0 ? '+' : ''}${profit.abs() < 10 ? profit.toStringAsFixed(2) : profit.toStringAsFixed(1)}',
|
||
|
|
style: TextStyle(
|
||
|
|
fontSize: 7,
|
||
|
|
fontWeight: FontWeight.w600,
|
||
|
|
color: profit > 0 ? _upColor : _downColor,
|
||
|
|
),
|
||
|
|
maxLines: 1,
|
||
|
|
overflow: TextOverflow.ellipsis,
|
||
|
|
),
|
||
|
|
],
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
if (cells.length == 7) {
|
||
|
|
rows.add(Row(children: cells));
|
||
|
|
cells = [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 填充月末空白
|
||
|
|
if (cells.isNotEmpty) {
|
||
|
|
while (cells.length < 7) {
|
||
|
|
cells.add(const Expanded(child: SizedBox.shrink()));
|
||
|
|
}
|
||
|
|
rows.add(Row(children: cells));
|
||
|
|
}
|
||
|
|
|
||
|
|
return rows;
|
||
|
|
}
|
||
|
|
}
|