361 lines
11 KiB
Dart
361 lines
11 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_spacing.dart';
|
|
import '../../../core/theme/app_theme_extension.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());
|
|
}
|
|
|
|
// ============================================
|
|
// 數據加載
|
|
// ============================================
|
|
|
|
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 now = DateTime.now();
|
|
final isCurrentMonth =
|
|
_currentMonth.year == now.year && _currentMonth.month == now.month;
|
|
|
|
return Scaffold(
|
|
backgroundColor: context.colors.surface,
|
|
appBar: AppBar(
|
|
title: const Text('盈虧分析'),
|
|
backgroundColor: context.colors.surface,
|
|
elevation: 0,
|
|
scrolledUnderElevation: 0,
|
|
centerTitle: true,
|
|
),
|
|
body: SingleChildScrollView(
|
|
padding: EdgeInsets.all(AppSpacing.lg),
|
|
child: Container(
|
|
padding: EdgeInsets.all(AppSpacing.lg),
|
|
decoration: BoxDecoration(
|
|
color: context.appColors.surfaceCard,
|
|
borderRadius: BorderRadius.circular(AppRadius.xl),
|
|
border: Border.all(color: context.appColors.ghostBorder),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// 月度盈虧摘要
|
|
_buildSummarySection(),
|
|
SizedBox(height: AppSpacing.md),
|
|
|
|
// 月份導航
|
|
_buildMonthNavigation(isCurrentMonth),
|
|
SizedBox(height: AppSpacing.sm),
|
|
|
|
// 星期標題
|
|
_buildWeekdayHeaders(),
|
|
SizedBox(height: AppSpacing.xs),
|
|
|
|
// 日曆網格
|
|
if (_isLoading)
|
|
_buildLoadingIndicator()
|
|
else
|
|
..._buildCalendarGrid(
|
|
now,
|
|
isCurrentMonth,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 月度盈虧摘要
|
|
Widget _buildSummarySection() {
|
|
final upColor = context.appColors.up;
|
|
final downColor = context.appColors.down;
|
|
final isProfit = _monthProfit >= 0;
|
|
final color = _isLoading ? context.colors.onSurfaceVariant : (isProfit ? upColor : downColor);
|
|
|
|
return Column(
|
|
children: [
|
|
Text(
|
|
'月度盈虧',
|
|
style: AppTextStyles.bodyMedium(context).copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
SizedBox(height: AppSpacing.xs),
|
|
Text(
|
|
_isLoading
|
|
? '--'
|
|
: '${isProfit ? '+' : ''}${_monthProfit.toStringAsFixed(2)} USDT',
|
|
style: AppTextStyles.displaySmall(context).copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: color,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 月份導航行
|
|
Widget _buildMonthNavigation(bool isCurrentMonth) {
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
// 上一月
|
|
GestureDetector(
|
|
onTap: _previousMonth,
|
|
child: Container(
|
|
padding: EdgeInsets.all(AppSpacing.xs + 1),
|
|
decoration: BoxDecoration(
|
|
color: context.colors.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
|
),
|
|
child: Icon(
|
|
LucideIcons.chevronLeft,
|
|
size: 16,
|
|
color: context.colors.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
|
|
? context.colors.surfaceContainerHigh.withValues(alpha: 0.5)
|
|
: context.colors.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
|
),
|
|
child: Icon(
|
|
LucideIcons.chevronRight,
|
|
size: 16,
|
|
color: isCurrentMonth
|
|
? context.colors.onSurfaceVariant.withValues(alpha: 0.4)
|
|
: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// 星期標題行
|
|
Widget _buildWeekdayHeaders() {
|
|
return Row(
|
|
children: ['一', '二', '三', '四', '五', '六', '日'].map((d) {
|
|
return Expanded(
|
|
child: Center(
|
|
child: Text(
|
|
d,
|
|
style: AppTextStyles.bodySmall(context).copyWith(
|
|
fontWeight: FontWeight.w500,
|
|
color: context.colors.onSurfaceVariant.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
|
|
/// 加載指示器
|
|
Widget _buildLoadingIndicator() {
|
|
return Padding(
|
|
padding: EdgeInsets.symmetric(vertical: AppSpacing.xxl),
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: context.colors.primary,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 日曆網格
|
|
List<Widget> _buildCalendarGrid(
|
|
DateTime now,
|
|
bool isCurrentMonth,
|
|
) {
|
|
final upColor = context.appColors.up;
|
|
final downColor = context.appColors.down;
|
|
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
|
|
? context.colors.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: context.colors.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
|
|
? context.colors.primary
|
|
: context.colors.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;
|
|
}
|
|
}
|