feat:增加公共组件

This commit is contained in:
2025-11-22 15:19:34 +08:00
parent bc08235794
commit f5ddc4ee04
17 changed files with 1762 additions and 3 deletions

View File

@@ -1,4 +1,10 @@
library flutter_common;
export 'layout/theme_layout.dart';
export 'provider/theme_provider.dart';
export 'widget/chart.dart';
export 'utils/date_utils.dart';
export 'utils/sp_utils.dart';

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../provider/theme_provider.dart';
import '../utils/theme_utils.dart';
class ThemeLayout extends StatelessWidget {
final Widget? child;
const ThemeLayout({super.key, this.child});
Widget _buildThemeColorList(BuildContext context, ThemeProvider provider) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 1.2,
),
itemCount: provider.availableThemes.length,
itemBuilder: (context, index) {
final themeColor = provider.availableThemes[index];
final isSelected = provider.currentTheme == themeColor;
return _buildThemeColorItem(
themeColor: themeColor,
isSelected: isSelected,
onTap: () => provider.changeTheme(themeColor),
);
},
),
);
}
Widget _buildThemeColorItem({
required ThemeColor themeColor,
required bool isSelected,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
decoration: BoxDecoration(
color:
isSelected
? themeColor.primaryColor.withAlpha(50)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected ? themeColor.primaryColor : Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 颜色圆点
Container(
width: 20,
height: 20,
decoration: BoxDecoration(
color: themeColor.primaryColor,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(10),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
),
const SizedBox(height: 4),
Text(
themeColor.name,
style: TextStyle(
fontSize: 10,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color:
isSelected ? themeColor.primaryColor : Colors.grey.shade600,
),
),
],
),
),
);
}
Widget _buildDarkModeSection(BuildContext context, ThemeProvider provider) {
return ListTile(
leading: Icon(
provider.isDarkMode ? Icons.dark_mode : Icons.light_mode,
color: Theme.of(context).colorScheme.primary,
),
title: const Text('暗黑模式'),
trailing: Switch(
value: provider.isDarkMode,
onChanged: (value) {
provider.toggleDarkMode(value);
},
),
onTap: () {
provider.toggleDarkMode(!provider.isDarkMode);
},
);
}
@override
Widget build(BuildContext context) {
final provider = context.watch<ThemeProvider>();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text('主题颜色', style: Theme.of(context).textTheme.titleMedium),
),
_buildDarkModeSection(context, provider),
_buildThemeColorList(context, provider),
],
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:json_annotation/json_annotation.dart';
import '../widget/chart.dart';
part 'common_model.g.dart';
@JsonSerializable(genericArgumentFactories: true)
class PageResult<T> {
@JsonKey(name: 'records')
final List<T> records;
@JsonKey(name: 'total')
final int total;
@JsonKey(name: 'size')
final int size;
@JsonKey(name: 'current')
final int current;
@JsonKey(name: 'pages')
final int pages;
const PageResult({
required this.records,
required this.total,
required this.size,
required this.current,
required this.pages,
});
factory PageResult.fromJson(
Map<String, dynamic> json,
T Function(Object? json) fromJsonT,
) =>
_$PageResultFromJson(json, fromJsonT);
Map<String, dynamic> toJson(Object? Function(T value) toJsonT) =>
_$PageResultToJson(this, toJsonT);
}
@JsonSerializable(genericArgumentFactories: true)
class ChartData {
@JsonKey(name: 'name')
final String name;
@JsonKey(name: 'value')
final num value;
const ChartData({required this.name, required this.value});
factory ChartData.fromJson(Map<String, dynamic> json) =>
_$ChartDataFromJson(json);
Map<String, dynamic> toJson() => _$ChartDataToJson(this);
}

View File

@@ -0,0 +1,41 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'common_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
PageResult<T> _$PageResultFromJson<T>(
Map<String, dynamic> json,
T Function(Object? json) fromJsonT,
) =>
PageResult<T>(
records: (json['records'] as List<dynamic>).map(fromJsonT).toList(),
total: (json['total'] as num).toInt(),
size: (json['size'] as num).toInt(),
current: (json['current'] as num).toInt(),
pages: (json['pages'] as num).toInt(),
);
Map<String, dynamic> _$PageResultToJson<T>(
PageResult<T> instance,
Object? Function(T value) toJsonT,
) =>
<String, dynamic>{
'records': instance.records.map(toJsonT).toList(),
'total': instance.total,
'size': instance.size,
'current': instance.current,
'pages': instance.pages,
};
ChartData _$ChartDataFromJson(Map<String, dynamic> json) => ChartData(
name: json['name'] as String,
value: json['value'] as num,
);
Map<String, dynamic> _$ChartDataToJson(ChartData instance) => <String, dynamic>{
'name': instance.name,
'value': instance.value,
};

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/theme_utils.dart';
class ThemeProvider with ChangeNotifier {
static const String _themeKey = 'selected_theme';
static const String _darkModeKey = 'is_dark_mode';
bool isDarkMode = false;
ThemeColor currentTheme = defaultThemes[0];
late SharedPreferences _prefs;
bool isInitialized = false;
ThemeProvider() {
_initPreferences();
}
// 初始化 SharedPreferences
Future<void> _initPreferences() async {
_prefs = await SharedPreferences.getInstance();
_loadPreferences();
isInitialized = true;
notifyListeners();
}
// 加载存储的设置
void _loadPreferences() {
isDarkMode = _prefs.getBool(_darkModeKey) ?? false;
// 加载主题色
final themeName = _prefs.getString(_themeKey);
if (themeName != null) {
currentTheme = getThemeColor(themeName);
}
}
// 保存主题色
Future<void> _saveTheme() async {
await _prefs.setString(_themeKey, currentTheme.name);
}
// 保存暗黑模式
Future<void> _saveDarkMode() async {
await _prefs.setBool(_darkModeKey, isDarkMode);
}
List<ThemeColor> get availableThemes => defaultThemes;
// 切换明暗模式
void toggleDarkMode(bool value) {
isDarkMode = value;
_saveDarkMode();
notifyListeners();
}
// 更改主题色
void changeTheme(ThemeColor theme) {
currentTheme = theme;
_saveTheme();
notifyListeners();
}
ThemeData get currentThemeData {
return ThemeData(
primarySwatch: currentTheme.materialColor,
colorScheme: ColorScheme.fromSeed(
seedColor: currentTheme.primaryColor,
brightness: isDarkMode ? Brightness.dark : Brightness.light,
),
useMaterial3: true,
fontFamily: 'CustomFont',
);
}
}

View File

@@ -0,0 +1,28 @@
import '../models/common_model.dart';
List<T> convertList<T>(
dynamic data,
T Function(Map<String, dynamic>) fromJson,
) {
if (data is List) {
return data.map((item) => fromJson(item as Map<String, dynamic>)).toList();
}
throw FormatException(
'Expected a list of items for conversion, but got ${data.runtimeType}',
);
}
PageResult<T> convertPage<T>(
dynamic data,
T Function(Map<String, dynamic>) fromJson,
) {
if (data is Map<String, dynamic>) {
return PageResult<T>.fromJson(
data,
(json) => fromJson(json as Map<String, dynamic>),
);
}
throw FormatException(
'Expected a Map for page response conversion, but got ${data.runtimeType}',
);
}

View File

@@ -11,3 +11,20 @@ String formatDateString(String date) {
String formatTime(DateTime datetime) {
return DateFormat('yyyy-MM-dd HH:mm:ss').format(datetime);
}
bool isToday(DateTime? date) {
if (date == null) return false;
final now = DateTime.now();
return date.year == now.year &&
date.month == now.month &&
date.day == now.day;
}
bool isAfterToday(DateTime? date) {
if (date == null) return false;
if (!isToday(date)) return false;
return date.isAfter(DateTime.now());
}

251
lib/utils/http_utils.dart Normal file
View File

@@ -0,0 +1,251 @@
import 'package:dio/dio.dart';
import 'log_utils.dart';
import 'sp_utils.dart';
class HttpUtil {
static HttpUtil? _instance;
factory HttpUtil({
required String baseUrl,
int timeout = 5,
}) {
_instance ??= HttpUtil._internal(
baseUrl: baseUrl,
timeout: timeout,
);
return _instance!;
}
final String baseUrl;
final int timeout;
late Dio _dio;
// 请求头配置
Map<String, dynamic> headers = {
'Content-Type': 'application/json;charset=UTF-8',
'Accept': 'application/json',
};
HttpUtil._internal({
required this.baseUrl,
required this.timeout,
}) {
_initDio();
}
void _initDio() {
BaseOptions options = BaseOptions(
baseUrl: baseUrl,
connectTimeout: Duration(seconds: timeout),
receiveTimeout: Duration(seconds: timeout),
sendTimeout: Duration(seconds: timeout),
headers: headers,
);
_dio = Dio(options);
// 添加请求拦截器
_dio.interceptors.add(
InterceptorsWrapper(
onRequest: (options, handler) {
logger.d("请求URL: ${options.uri}");
if (options.data != null) {
logger.d("请求参数: ${options.data}");
}
if (SPUtil.getString('token').isNotEmpty) {
options.headers['satoken'] = SPUtil.getString('token');
}
return handler.next(options);
},
),
);
// 添加响应拦截器
_dio.interceptors.add(
InterceptorsWrapper(
onResponse: (response, handler) {
logger.d("响应状态码: ${response.statusCode}");
// logger.d("响应数据: ${response.data}");
return handler.next(response);
},
),
);
// 添加错误拦截器
_dio.interceptors.add(
InterceptorsWrapper(
onError: (DioException e, handler) {
_handleError(e);
return handler.next(e);
},
),
);
}
// 基础请求方法(处理所有类型的请求)
Future<T> _request<T>(
String path, {
required String method,
dynamic data,
Map<String, dynamic>? queryParameters,
T Function(dynamic data)? converter,
}) async {
try {
Response response = await _dio.request(
path,
data: data,
queryParameters: queryParameters,
options: Options(method: method),
);
// 处理响应数据
if (converter != null) {
return converter(response.data);
}
// 没有转换器时尝试直接返回(可能不安全,建议提供转换器)
return response.data;
} catch (e) {
rethrow;
}
}
// GET请求
Future<T> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
T Function(dynamic data)? converter,
}) =>
_request(
path,
method: "GET",
queryParameters: queryParameters,
converter: converter,
);
// POST请求
Future<T> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
T Function(dynamic data)? converter,
}) =>
_request(
path,
method: "POST",
data: data,
queryParameters: queryParameters,
converter: converter,
);
// PUT请求
Future<T> put<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
T Function(dynamic data)? converter,
}) =>
_request(
path,
method: "PUT",
data: data,
queryParameters: queryParameters,
converter: converter,
);
// DELETE请求
Future<T> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
T Function(dynamic data)? converter,
}) =>
_request(
path,
method: "DELETE",
data: data,
queryParameters: queryParameters,
converter: converter,
);
// 错误处理
void _handleError(dynamic error) {
String errorMessage = '未知错误';
if (error is DioException) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
errorMessage = '连接超时,请检查网络连接';
break;
case DioExceptionType.sendTimeout:
errorMessage = '发送超时,请检查网络连接';
break;
case DioExceptionType.receiveTimeout:
errorMessage = '接收超时,请检查网络连接';
break;
case DioExceptionType.cancel:
errorMessage = '请求已取消';
break;
case DioExceptionType.badCertificate:
errorMessage = '证书验证失败';
break;
case DioExceptionType.badResponse:
// 处理HTTP响应错误4xx, 5xx
final statusCode = error.response?.statusCode ?? 0;
final responseData = error.response?.data;
if (responseData is Map<String, dynamic> &&
responseData.containsKey('message')) {
// 服务器返回了自定义错误消息
errorMessage = responseData['message']?.toString() ?? '未知错误';
} else {
errorMessage = _getHttpErrorMessage(statusCode);
}
break;
case DioExceptionType.connectionError:
errorMessage = '网络连接错误,请检查网络设置';
break;
case DioExceptionType.unknown:
if (error.error != null) {
errorMessage = '未知错误: ${error.error.toString()}';
}
errorMessage = '发生未知错误';
break;
}
} else {
errorMessage = '非Dio错误: $error';
}
logger.e(errorMessage);
// showErrorToast(errorMessage);
}
String _getHttpErrorMessage(int statusCode) {
// 根据HTTP状态码返回对应的错误消息
switch (statusCode) {
case 400:
return '错误请求,请检查参数';
case 401:
return '未授权,请登录';
case 403:
return '禁止访问,权限不足';
case 404:
return '资源不存在';
case 405:
return '方法不允许';
case 408:
return '请求超时';
case 500:
return '服务器内部错误';
case 502:
return '网关错误';
case 503:
return '服务不可用';
case 504:
return '网关超时';
default:
return 'HTTP错误: $statusCode';
}
}
}

12
lib/utils/log_utils.dart Normal file
View File

@@ -0,0 +1,12 @@
import 'package:logger/logger.dart';
// 全局日志实例,在整个项目中共享
final Logger logger = Logger(
printer: PrettyPrinter(
methodCount: 1,
colors: true,
dateTimeFormat: DateTimeFormat.dateAndTime,
),
// 可选:配置输出到文件(需配合文件操作库)
// output: FileOutput(file: File('logs/app.log')),
);

View File

@@ -0,0 +1,24 @@
/// 将数字格式化为中文单位(万/亿)
String formatChineseDecimal(num number, {int decimalPlaces = 2}) {
if (number < 10000) {
return number.toString();
} else if (number < 100000000) {
// 万单位
double result = number / 10000.0;
return '${_formatDecimal(result, decimalPlaces)}';
} else {
// 亿单位
double result = number / 100000000.0;
return '${_formatDecimal(result, decimalPlaces)}亿';
}
}
String _formatDecimal(double number, int decimalPlaces) {
// 去除末尾的0和小数点
String formatted = number.toStringAsFixed(decimalPlaces);
if (formatted.contains('.')) {
formatted = formatted.replaceAll(RegExp(r'0*$'), '');
formatted = formatted.replaceAll(RegExp(r'\.$'), '');
}
return formatted;
}

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
class ThemeColor {
final String name;
final Color primaryColor;
final MaterialColor materialColor;
ThemeColor({
required this.name,
required this.primaryColor,
required this.materialColor,
});
}
final List<ThemeColor> defaultThemes = [
ThemeColor(
name: '梦幻紫',
primaryColor: Color(0xFF8B5CF6),
materialColor: MaterialColor(0xFF8B5CF6, {
50: Color(0xFFF5F3FF),
100: Color(0xFFEDE9FE),
200: Color(0xFFDDD6FE),
300: Color(0xFFC4B5FD),
400: Color(0xFFA78BFA),
500: Color(0xFF8B5CF6),
600: Color(0xFF7C3AED),
700: Color(0xFF6D28D9),
800: Color(0xFF5B21B6),
900: Color(0xFF4C1D95),
}),
),
ThemeColor(
name: '活力橙',
primaryColor: Color(0xFFF59E0B),
materialColor: MaterialColor(0xFFF59E0B, {
50: Color(0xFFFFFBEB),
100: Color(0xFFFEF3C7),
200: Color(0xFFFDE68A),
300: Color(0xFFFCD34D),
400: Color(0xFFFBBF24),
500: Color(0xFFF59E0B),
600: Color(0xFFD97706),
700: Color(0xFFB45309),
800: Color(0xFF92400E),
900: Color(0xFF78350F),
}),
),
ThemeColor(
name: '浪漫粉',
primaryColor: Color(0xFFEC4899),
materialColor: MaterialColor(0xFFEC4899, {
50: Color(0xFFFDF2F8),
100: Color(0xFFFCE7F3),
200: Color(0xFFFBCFE8),
300: Color(0xFFF9A8D4),
400: Color(0xFFF472B6),
500: Color(0xFFEC4899),
600: Color(0xFFDB2777),
700: Color(0xFFBE185D),
800: Color(0xFF9D174D),
900: Color(0xFF831843),
}),
),
ThemeColor(
name: '清新青',
primaryColor: Color(0xFF06B6D4),
materialColor: MaterialColor(0xFF06B6D4, {
50: Color(0xFFF0FDFA),
100: Color(0xFFCCFBF1),
200: Color(0xFF99F6E4),
300: Color(0xFF5EEAD4),
400: Color(0xFF2DD4BF),
500: Color(0xFF06B6D4),
600: Color(0xFF0891B2),
700: Color(0xFF0E7490),
800: Color(0xFF155E75),
900: Color(0xFF164E63),
}),
),
];
ThemeColor getThemeColor(String themeName) {
return defaultThemes.firstWhere(
(theme) => theme.name == themeName,
orElse: () => defaultThemes[0],
);
}

435
lib/widget/chart.dart Normal file
View File

@@ -0,0 +1,435 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_charts/charts.dart';
import '../models/common_model.dart';
class LineChart extends StatelessWidget {
final String title;
final String xAxisName;
final String yAxisName;
final String unit;
final List<ChartData> data;
const LineChart(
{super.key,
required this.title,
required this.xAxisName,
required this.yAxisName,
required this.unit,
required this.data});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(
children: [
_buildChartTitle(context, title, Icons.show_chart),
const SizedBox(height: 3),
_buildChartDivider(context),
const SizedBox(height: 3),
Expanded(
child: SfCartesianChart(
// 图表标题
// title: ChartTitle(text: ''),
// X轴配置类别轴
primaryXAxis: CategoryAxis(majorGridLines: MajorGridLines(width: 0)),
// Y轴配置数值轴
// primaryYAxis: NumericAxis(title: AxisTitle(text: '$yAxisName$unit')),
// 启用图例
legend: Legend(isVisible: true, position: LegendPosition.top),
// 启用交互提示(点击数据点显示详情)
tooltipBehavior: TooltipBehavior(
enable: true,
format: 'point.x: point.y $unit',
),
// 折线图数据系列
series: [
LineSeries<ChartData, String>(
dataSource: data,
// X轴数据映射
xValueMapper: (ChartData chart, _) => chart.name,
// Y轴数据映射
yValueMapper: (ChartData chart, _) => chart.value,
// 线条颜色
color: colors.primary,
// 线条宽度
width: 3,
// 数据点样式
markerSettings: const MarkerSettings(
isVisible: true,
color: Colors.white,
shape: DataMarkerType.circle,
height: 6,
width: 6,
),
// 折线名称(会显示在图例中)
name: yAxisName,
// 启用数据标签(直接显示数值)
dataLabelSettings: const DataLabelSettings(
isVisible: true,
color: Colors.white,
opacity: 0,
),
// 动画效果
animationDuration: 2000, // 动画时长(毫秒)
),
],
)),
],
);
}
}
class BarChart extends StatelessWidget {
final String title;
final String xAxisName;
final String yAxisName;
final String unit;
final List<ChartData> data;
const BarChart(
{super.key,
required this.title,
required this.xAxisName,
required this.yAxisName,
required this.unit,
required this.data});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Column(children: [
_buildChartTitle(context, title, Icons.bar_chart),
const SizedBox(height: 3),
_buildChartDivider(context),
const SizedBox(height: 3),
Expanded(
child: SfCartesianChart(
// X轴配置类别轴
primaryXAxis: CategoryAxis(majorGridLines: MajorGridLines(width: 0)),
// Y轴配置数值轴
// primaryYAxis: NumericAxis(title: AxisTitle(text: '$yAxisName$unit')),
// 启用交互提示
tooltipBehavior: TooltipBehavior(
enable: true,
format: 'point.x: point.y $unit',
),
// 柱状图数据系列
series: [
ColumnSeries<ChartData, String>(
dataSource: data,
// X轴数据映射
xValueMapper: (ChartData chart, _) => chart.name,
// Y轴数据映射
yValueMapper: (ChartData chart, _) => chart.value,
// 名称
name: yAxisName,
// 柱子颜色
color: colors.primary,
// 柱子宽度0-1之间1表示占满类别间隔
width: 0.6,
// 柱子边框
borderWidth: 1,
borderColor: Colors.black12,
// 数据标签
dataLabelSettings: const DataLabelSettings(
isVisible: true,
color: Colors.white,
opacity: 0,
alignment: ChartAlignment.center,
),
// 动画效果
animationDuration: 2000,
),
],
))
]);
}
}
class DoubleBarChart extends StatelessWidget {
final String xAxisName;
final String yAxisName;
final String unit;
final List<ChartData> data1;
final List<ChartData> data2;
final String series1Name;
final String series2Name;
const DoubleBarChart(
{super.key,
required this.xAxisName,
required this.yAxisName,
required this.unit,
required this.data1,
required this.data2,
required this.series1Name,
required this.series2Name});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return SfCartesianChart(
// X轴配置类别轴
primaryXAxis:
CategoryAxis(majorGridLines: const MajorGridLines(width: 0)),
// Y轴配置数值轴
// primaryYAxis: NumericAxis(title: AxisTitle(text: '$yAxisName$unit')),
// 图例配置
legend: Legend(
isVisible: true,
position: LegendPosition.top,
overflowMode: LegendItemOverflowMode.wrap,
),
// 启用交互提示
tooltipBehavior: TooltipBehavior(
enable: true,
format: 'series.name: point.y $unit',
),
// 双柱状图数据系列
series: <ColumnSeries<ChartData, String>>[
ColumnSeries<ChartData, String>(
dataSource: data1,
// X轴数据映射
xValueMapper: (ChartData chart, _) => chart.name,
// Y轴数据映射
yValueMapper: (ChartData chart, _) => chart.value,
// 系列名称
name: series1Name,
// 柱子颜色
color: colors.primary,
// 柱子宽度
width: 0.3,
// 柱子边框
borderWidth: 1,
borderColor: Colors.black12,
// 数据标签
dataLabelSettings: const DataLabelSettings(
isVisible: true,
color: Colors.white,
opacity: 0,
alignment: ChartAlignment.center,
),
// 动画效果
animationDuration: 2000,
),
ColumnSeries<ChartData, String>(
dataSource: data2,
// X轴数据映射
xValueMapper: (ChartData chart, _) => chart.name,
// Y轴数据映射
yValueMapper: (ChartData chart, _) => chart.value,
// 系列名称
name: series2Name,
// 柱子颜色
color: colors.inversePrimary,
// 柱子宽度
width: 0.3,
// 柱子边框
borderWidth: 1,
borderColor: Colors.black12,
// 数据标签
dataLabelSettings: const DataLabelSettings(
isVisible: true,
color: Colors.white,
opacity: 0,
alignment: ChartAlignment.center,
),
// 动画效果
animationDuration: 2000,
),
],
);
}
}
class PieChart extends StatelessWidget {
final String title;
final String unit;
final List<ChartData> data;
const PieChart(
{super.key, required this.title, required this.unit, required this.data});
@override
Widget build(BuildContext context) {
// 计算 value 的总和
double sumValue = data.fold(0.0, (sum, item) => sum + item.value);
return Column(children: [
_buildChartTitle(context, title, Icons.pie_chart),
const SizedBox(height: 3),
_buildChartDivider(context),
const SizedBox(height: 3),
Expanded(
child: SfCircularChart(
// 饼图标题
// title: ChartTitle(text: ''),
// 启用图例
legend: const Legend(isVisible: true, position: LegendPosition.right),
// 启用交互提示(点击扇区显示详情)
tooltipBehavior: TooltipBehavior(
enable: true,
format: 'point.x: point.y $unit',
),
// 饼图系列配置
series: [
PieSeries<ChartData, String>(
dataSource: data,
// 类别映射(饼图扇区名称)
xValueMapper: (ChartData data, _) => data.name,
// 数值映射(扇区大小占比)
yValueMapper: (ChartData data, _) => data.value,
// 扇区半径0-1之间1表示充满容器
// radius: '50%',
// 启用扇区分离效果
explode: true,
// 指定分离的扇区索引(这里分离第一个扇区)
explodeIndex: 0,
// 分离距离
explodeOffset: '5%',
dataLabelMapper: (ChartData data, _) {
final percentage =
(data.value / sumValue * 100).toStringAsFixed(0);
return '$percentage%';
},
// 数据标签(显示在扇区上的文本)
dataLabelSettings: DataLabelSettings(isVisible: true),
// 动画效果
animationDuration: 2000,
),
],
))
]);
}
}
class RankChart extends StatelessWidget {
final String title;
final String unit;
final List<ChartData> data;
const RankChart(
{super.key, required this.title, required this.unit, required this.data});
@override
Widget build(BuildContext context) {
return Column(children: [
_buildChartTitle(context, title, Icons.bar_chart),
const SizedBox(height: 3),
_buildChartDivider(context),
const SizedBox(height: 3),
Expanded(
child: ListView.builder(
itemCount: data.length,
itemBuilder: (context, index) {
final item = data[index];
final rank = index + 1;
return SizedBox(
height: 35,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
width: 25,
alignment: Alignment.center,
child: Text(
'$rank.',
style: TextStyle(
fontWeight: FontWeight.bold,
color: _getRankColor(context, index),
),
),
),
Text(
item.name,
style: TextStyle(
fontWeight:
index < 3 ? FontWeight.bold : FontWeight.normal,
color: _getRankColor(context, index),
),
),
],
),
Text(
'${item.value.toStringAsFixed(0)} $unit',
style: TextStyle(
fontWeight: index < 3 ? FontWeight.bold : FontWeight.normal,
color: _getRankColor(context, index),
),
),
],
),
);
},
))
]);
}
}
Widget _buildChartTitle(BuildContext context, String title, IconData icon) {
return Row(
children: [
Icon(icon, color: Theme.of(context).colorScheme.primary),
Text(title),
],
);
}
Widget _buildChartDivider(BuildContext context) {
return Divider(
height: 1,
thickness: 1,
color: Theme.of(context).colorScheme.primary,
indent: 0,
endIndent: 0,
);
}
Color _getRankColor(BuildContext context, int index) {
switch (index) {
case 0: // 金牌
return const Color(0xFFFFD700); // 标准金色
case 1: // 银牌
return const Color(0xFFC0C0C0); // 标准银色
case 2: // 铜牌
return const Color(0xFFCD7F32); // 标准铜色
default:
return Theme.of(context).colorScheme.onSurface; // 普通颜色
}
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
class CommonCard extends StatelessWidget {
final Widget? child;
const CommonCard({super.key, this.child});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: colors.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(16),
child: child,
);
}
}
class CircleIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onPressed;
const CircleIconButton(
{super.key, required this.icon, required this.onPressed});
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
shape: CircleBorder(),
elevation: 0,
backgroundColor: colors.primary,
padding: EdgeInsets.zero,
minimumSize: const Size(0, 0),
),
child: Icon(icon, color: Colors.white),
);
}
}
Widget buildErrorInfo({
required String errorInfo,
required VoidCallback onPressed,
}) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('加载失败: $errorInfo'),
ElevatedButton(onPressed: onPressed, child: Text('重试')),
],
),
);
}
Widget buildLoadingIndicator() {
return Center(child: CircularProgressIndicator());
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import 'common_widget.dart';
class YearSelector extends StatefulWidget {
final int initialYear;
final int? minYear;
final int? maxYear;
final Function(int) onYearChanged;
const YearSelector({
super.key,
required this.initialYear,
required this.onYearChanged,
this.minYear,
this.maxYear,
});
@override
State<YearSelector> createState() => _YearSelectorState();
}
// SingleTickerProviderStateMixin 动画控制器
class _YearSelectorState extends State<YearSelector>
with SingleTickerProviderStateMixin {
late int _currentYear;
// 用于动画效果
late AnimationController _animationController;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_currentYear = widget.initialYear;
// 初始化动画控制器
_animationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 200),
);
// 缩放动画
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.1).animate(
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
/// 切换到上一年
void _previousYear() {
if (widget.minYear == null || _currentYear > widget.minYear!) {
_animateYearChange(() {
setState(() {
_currentYear--;
});
widget.onYearChanged(_currentYear);
});
}
}
/// 切换到下一年
void _nextYear() {
if (widget.maxYear == null || _currentYear < widget.maxYear!) {
_animateYearChange(() {
setState(() {
_currentYear++;
});
widget.onYearChanged(_currentYear);
});
}
}
/// 年份变化时的动画效果
void _animateYearChange(VoidCallback onComplete) {
_animationController.forward().then((_) {
onComplete();
_animationController.reverse();
});
}
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircleIconButton(
icon: Icons.chevron_left,
onPressed: () => _previousYear(),
),
AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(scale: _scaleAnimation.value, child: child);
},
child: Text(
'$_currentYear',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
CircleIconButton(
icon: Icons.chevron_right,
onPressed: () => _nextYear(),
)
],
);
}
}