Browse Source

[new]增加地点占比时长饼图显示

zk 5 months ago
parent
commit
781c53aa03

BIN
assets/images/bg_track_pie_chat.webp


+ 51 - 0
lib/module/track/track_day_detail/time_proportion/pie_chat_data.dart

@@ -0,0 +1,51 @@
+import 'dart:ui';
+
+class PieChatData {
+  String address;
+
+  Color color;
+
+  int duration;
+
+  double proportion = 0.0;
+
+  PieChatData({
+    required this.address,
+    required this.color,
+    required this.duration,
+  });
+}
+
+//pie_chat 颜色集合
+
+List<Color> pieChatColors = [
+  const Color(0xFF228B22),
+  const Color(0xFF30AE6A),
+  const Color(0xFF657B4B),
+  const Color(0xFF2DC372),
+  const Color(0xFF30CB30),
+  const Color(0xFF89C919),
+  const Color(0xFF42CACA),
+  const Color(0xFF3D8E8E),
+  const Color(0xFF60B0B0),
+  const Color(0xFFA6612F),
+  const Color(0xFFDB8055),
+  const Color(0xFF4169E1),
+  const Color(0xFF4682B4),
+  const Color(0xFF2B9BD6),
+  const Color(0xFF82C7F2),
+  const Color(0xFFA47FE3),
+  const Color(0xFFB8860B),
+  const Color(0xFFE4BB16),
+  const Color(0xFFB69174),
+  const Color(0xFFD23C3C),
+  const Color(0xFF8B008B),
+  const Color(0xFFCD5C5C),
+  const Color(0xFF9932CC),
+  const Color(0xFF8B4513),
+  const Color(0xFFFF8C00),
+  const Color(0xFF20B2AA),
+  const Color(0xFFDC143C),
+  const Color(0xFF708090),
+  const Color(0xFFFF1493),
+];

+ 177 - 0
lib/module/track/track_day_detail/time_proportion/test.dart

@@ -0,0 +1,177 @@
+import 'dart:math';
+import 'package:flutter/material.dart';
+import 'package:fl_chart/fl_chart.dart';
+
+void main() {
+  runApp(const MaterialApp(
+    home: Scaffold(
+      backgroundColor: Colors.white,
+      body: Center(child: MySolidPieChart()),
+    ),
+  ));
+}
+
+class MySolidPieChart extends StatefulWidget {
+  const MySolidPieChart({super.key});
+
+  @override
+  State<MySolidPieChart> createState() => _MySolidPieChartState();
+}
+
+class _MySolidPieChartState extends State<MySolidPieChart> {
+  int? touchedIndex;
+  final double baseRadius = 60.0;
+
+  final List<_PieData> data = [
+    _PieData(label: "运行", value: 45, color: Colors.teal),
+    _PieData(label: "移动", value: 14, color: Colors.blue),
+    _PieData(label: "停留", value: 25, color: Colors.green),
+    _PieData(label: "其他", value: 16, color: Colors.orange),
+  ];
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      width: 320,
+      height: 320,
+      child: Stack(
+        children: [
+          PieChart(
+            PieChartData(
+              sectionsSpace: 0,
+              centerSpaceRadius: 0,
+              // ✅ 实心饼图
+              startDegreeOffset: -90,
+              pieTouchData: PieTouchData(
+                touchCallback: (event, response) {
+                  setState(() {
+                    final index = response?.touchedSection?.touchedSectionIndex;
+                    touchedIndex = (index != null && index >= 0) ? index : null;
+                  });
+                },
+              ),
+              sections: List.generate(data.length, (i) {
+                final item = data[i];
+                final isTouched = i == touchedIndex;
+                return PieChartSectionData(
+                  color: item.color,
+                  value: item.value.toDouble(),
+                  title: '${item.value}%',
+                  radius: isTouched ? baseRadius + 10 : baseRadius,
+                  titleStyle: const TextStyle(
+                    fontSize: 14,
+                    fontWeight: FontWeight.bold,
+                    color: Colors.white,
+                  ),
+                  titlePositionPercentageOffset: 0.6,
+                );
+              }),
+            ),
+          ),
+          IgnorePointer(
+            child: CustomPaint(
+              size: const Size(320, 320),
+              painter: PieLineLabelPainter(
+                center: const Offset(160, 160),
+                baseRadius: baseRadius + 10,
+                startAngleOffset: -90,
+                data: data,
+                touchedIndex: touchedIndex,
+              ),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+}
+
+class PieLineLabelPainter extends CustomPainter {
+  final Offset center;
+  final double baseRadius;
+  final double startAngleOffset;
+  final List<_PieData> data;
+  final int? touchedIndex;
+
+  PieLineLabelPainter({
+    required this.center,
+    required this.baseRadius,
+    required this.startAngleOffset,
+    required this.data,
+    required this.touchedIndex,
+  });
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    if (touchedIndex == null ||
+        touchedIndex! < 0 ||
+        touchedIndex! >= data.length) return;
+
+    final paint = Paint()
+      ..color = data[touchedIndex!].color
+      ..strokeWidth = 1.5;
+
+    final total = data.fold(0, (sum, e) => sum + e.value);
+    double angle = startAngleOffset * pi / 180;
+
+    for (int i = 0; i < data.length; i++) {
+      final sweepAngle = (data[i].value / total) * 2 * pi;
+
+      if (i == touchedIndex) {
+        final midAngle = angle + sweepAngle / 2;
+
+        final startR = baseRadius + 2;
+        final bendR = startR + 25;
+
+        final start = Offset(
+          center.dx + cos(midAngle) * startR,
+          center.dy + sin(midAngle) * startR,
+        );
+
+        final bend = Offset(
+          center.dx + cos(midAngle) * bendR,
+          center.dy + sin(midAngle) * bendR,
+        );
+
+        final isRight = cos(midAngle) >= 0;
+        final end = Offset(
+          bend.dx + (isRight ? 30 : -30),
+          bend.dy,
+        );
+
+        canvas.drawCircle(start, 3, paint);
+        canvas.drawLine(start, bend, paint);
+        canvas.drawLine(bend, end, paint);
+
+        final textPainter = TextPainter(
+          text: TextSpan(
+            text: '${data[i].label} 11h30min',
+            style: TextStyle(color: data[i].color, fontSize: 12),
+          ),
+          textDirection: TextDirection.ltr,
+        )..layout();
+
+        textPainter.paint(
+          canvas,
+          Offset(
+            isRight ? end.dx + 4 : end.dx - textPainter.width - 4,
+            end.dy - 6,
+          ),
+        );
+      }
+
+      angle += sweepAngle;
+    }
+  }
+
+  @override
+  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
+}
+
+class _PieData {
+  final String label;
+  final int value;
+  final Color color;
+
+  _PieData({required this.label, required this.value, required this.color});
+}

+ 0 - 3
lib/module/track/track_day_detail/time_proportion/time_proportion_controller.dart

@@ -1,3 +0,0 @@
-import 'package:location/base/base_controller.dart';
-
-class TimeProportionController extends BaseController {}

+ 0 - 31
lib/module/track/track_day_detail/time_proportion/time_proportion_view.dart

@@ -1,31 +0,0 @@
-import 'package:flutter/cupertino.dart';
-import 'package:flutter/src/widgets/framework.dart';
-import 'package:flutter_screenutil/flutter_screenutil.dart';
-import 'package:location/base/base_view.dart';
-import 'package:location/module/track/track_day_detail/time_proportion/time_proportion_controller.dart';
-import 'package:location/utils/common_expand.dart';
-
-import '../../../../resource/string.gen.dart';
-
-class TimeProportionView extends BaseView<TimeProportionController> {
-  const TimeProportionView({super.key});
-
-  @override
-  Widget buildBody(BuildContext context) {
-    return Container(
-      padding: EdgeInsets.all(12.w),
-      child: Column(
-        children: [
-          Align(
-            alignment: Alignment.centerLeft,
-            child: Text(StringName.trackDetailTimeProportion,
-                style: TextStyle(
-                    fontSize: 13.sp,
-                    color: '#333333'.color,
-                    fontWeight: FontWeight.bold)),
-          )
-        ],
-      ),
-    );
-  }
-}

+ 258 - 0
lib/module/track/track_day_detail/time_proportion/track_time_pie_chat.dart

@@ -0,0 +1,258 @@
+import 'dart:math';
+import 'package:fl_chart/fl_chart.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_screenutil/flutter_screenutil.dart';
+import 'package:location/module/track/track_day_detail/time_proportion/pie_chat_data.dart';
+import 'package:location/resource/assets.gen.dart';
+import 'package:location/utils/common_expand.dart';
+
+import '../../track_util.dart';
+
+class TrackTimePieChat extends StatefulWidget {
+  final List<PieChatData> pieData;
+
+  const TrackTimePieChat({required this.pieData, super.key});
+
+  @override
+  State<TrackTimePieChat> createState() => _TrackTimePieChatState();
+}
+
+class _TrackTimePieChatState extends State<TrackTimePieChat> {
+  int touchedIndex = 0;
+  final double baseRadius = 52.w;
+  final double bgChatSize = 216.w;
+
+  @override
+  void initState() {
+    super.initState();
+    if (widget.pieData.isNotEmpty) {
+      setDefaultMaxIndex();
+    }
+  }
+
+  @override
+  void didUpdateWidget(covariant TrackTimePieChat oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (widget.pieData != oldWidget.pieData && widget.pieData.isNotEmpty) {
+      setDefaultMaxIndex();
+    } else if (widget.pieData.isEmpty) {
+      touchedIndex = 0;
+    }
+  }
+
+  //设置占比最大一个为默认的index
+  void setDefaultMaxIndex() {
+    final maxProportion =
+        widget.pieData.map((e) => e.proportion).reduce((a, b) => a > b ? a : b);
+    touchedIndex =
+        widget.pieData.indexWhere((e) => e.proportion == maxProportion);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Column(
+      children: [
+        SizedBox(height: 10.w),
+        buildPieChatView(),
+        SizedBox(height: 6.w),
+        buildSelectPieTextView(),
+        Spacer(),
+      ],
+    );
+  }
+
+  Widget buildSelectPieTextView() {
+    return IntrinsicWidth(
+      child: Row(
+        mainAxisAlignment: MainAxisAlignment.center,
+        children: [
+          Container(
+            width: 12.w,
+            height: 12.w,
+            decoration: BoxDecoration(
+              color: widget.pieData[touchedIndex].color,
+            ),
+          ),
+          SizedBox(width: 6.w),
+          Expanded(
+            child: Text(
+              widget.pieData[touchedIndex].address,
+              style: TextStyle(
+                  fontSize: 12.sp,
+                  color: '#333333'.color,
+                  fontWeight: FontWeight.bold),
+            ),
+          ),
+        ],
+      ),
+    );
+  }
+
+  Widget buildPieChatView() {
+    return Container(
+      width: bgChatSize,
+      height: bgChatSize,
+      decoration: BoxDecoration(
+        image: DecorationImage(
+          image: Assets.images.bgTrackPieChat.provider(),
+        ),
+      ),
+      child: Stack(
+        children: [
+          PieChart(
+            PieChartData(
+              sectionsSpace: 0,
+              centerSpaceRadius: 0,
+              startDegreeOffset: -90,
+              pieTouchData: PieTouchData(
+                touchCallback: (event, response) {
+                  setState(() {
+                    final index = response?.touchedSection?.touchedSectionIndex;
+                    if (index != null && index >= 0) {
+                      touchedIndex = index;
+                    }
+                  });
+                },
+              ),
+              sections: List.generate(widget.pieData.length, (i) {
+                final item = widget.pieData[i];
+                return PieChartSectionData(
+                  color: item.color,
+                  value: item.proportion,
+                  title: '${item.proportion}%',
+                  radius: baseRadius,
+                  titleStyle: TextStyle(
+                    fontSize: 12.sp,
+                    fontWeight: FontWeight.bold,
+                    color: Colors.white,
+                  ),
+                  titlePositionPercentageOffset:
+                      widget.pieData.length <= 1 ? 0 : 0.6,
+                );
+              }),
+            ),
+          ),
+          buildIgnorePointer()
+        ],
+      ),
+    );
+  }
+
+  Widget buildIgnorePointer() {
+    return IgnorePointer(
+      child: CustomPaint(
+        size: Size(bgChatSize, bgChatSize),
+        painter: PieLineLabelPainter(
+          center: Offset(bgChatSize / 2, bgChatSize / 2),
+          baseRadius: baseRadius,
+          startAngleOffset: -90,
+          data: widget.pieData,
+          touchedIndex: touchedIndex,
+        ),
+      ),
+    );
+  }
+}
+
+class PieLineLabelPainter extends CustomPainter {
+  final Offset center;
+  final double baseRadius;
+  final double startAngleOffset;
+  final List<PieChatData> data;
+  final int? touchedIndex;
+
+  PieLineLabelPainter({
+    required this.center,
+    required this.baseRadius,
+    required this.startAngleOffset,
+    required this.data,
+    required this.touchedIndex,
+  });
+
+  @override
+  void paint(Canvas canvas, Size size) {
+    if (touchedIndex == null ||
+        touchedIndex! < 0 ||
+        touchedIndex! >= data.length) {
+      return;
+    }
+
+    final paint = Paint()
+      ..color = data[touchedIndex!].color
+      ..strokeWidth = 1.5;
+
+    final total = data.fold(0, (sum, e) => sum + e.proportion.toInt());
+    double angle = startAngleOffset * pi / 180;
+
+    for (int i = 0; i < data.length; i++) {
+      final sweepAngle = (data[i].proportion / total) * 2 * pi;
+
+      if (i == touchedIndex) {
+        final midAngle = angle + sweepAngle / 2;
+
+        final startR = baseRadius + 1;
+        final bendR = startR + 25;
+
+        final start = Offset(
+          center.dx + cos(midAngle) * startR,
+          center.dy + sin(midAngle) * startR,
+        );
+
+        final bend = Offset(
+          center.dx + cos(midAngle) * bendR,
+          center.dy + sin(midAngle) * bendR,
+        );
+
+        final isRight = cos(midAngle) >= 0;
+        final end = Offset(
+          bend.dx + (isRight ? 30 : -30),
+          bend.dy,
+        );
+
+        canvas.drawLine(start, bend, paint);
+        canvas.drawLine(bend, end, paint);
+        canvas.drawCircle(end, 3, paint);
+
+        // 文本与色块显示在 end 的上方
+        double rectSize = 8.w;
+        const padding = 2;
+
+        final textPainter = TextPainter(
+          text: TextSpan(
+            text: '停留${TrackUtil.formatDurationFromMillis(data[i].duration)}',
+            style: TextStyle(color: '#666666'.color, fontSize: 12.sp),
+          ),
+          textDirection: TextDirection.ltr,
+        )..layout();
+
+        final totalHeight = max(rectSize, textPainter.height);
+        final topY = end.dy - totalHeight - padding;
+
+        final rectOffset = Offset(
+          isRight ? end.dx : end.dx - (rectSize + textPainter.width + 6),
+          topY + (totalHeight - rectSize) / 2,
+        );
+
+        final textOffset = Offset(
+          isRight ? end.dx + rectSize + 6 : end.dx - textPainter.width,
+          topY + (totalHeight - textPainter.height) / 2,
+        );
+
+        canvas.drawRRect(
+          RRect.fromRectAndRadius(
+            Rect.fromLTWH(rectOffset.dx, rectOffset.dy, rectSize, rectSize),
+            const Radius.circular(2),
+          ),
+          Paint()..color = data[i].color,
+        );
+
+        textPainter.paint(canvas, textOffset);
+      }
+
+      angle += sweepAngle;
+    }
+  }
+
+  @override
+  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
+}

+ 44 - 0
lib/module/track/track_day_detail/track_day_detail_controller.dart

@@ -6,7 +6,9 @@ import 'package:location/data/repositories/track_repository.dart';
 import 'package:location/dialog/loading_dialog.dart';
 import 'package:location/handler/error_handler.dart';
 import 'package:location/module/track/track_controller.dart';
+import 'package:location/module/track/track_day_detail/time_proportion/pie_chat_data.dart';
 import 'package:location/resource/string.gen.dart';
+import 'package:location/utils/common_expand.dart';
 import 'package:location/utils/pair.dart';
 
 import '../../../data/bean/track_days.dart';
@@ -36,6 +38,9 @@ class TrackDayDetailController extends BaseController {
   Pair<TrackExpandType, TrackDailyBean>? get expandSituation =>
       _expandSituation.value;
 
+  final RxList<PieChatData> pieChatData = RxList<PieChatData>();
+  int indexPieChatColor = 0;
+
   final TrackController trackController = Get.find<TrackController>();
 
   TrackDayDetailController(this.days, bool isExpand) {
@@ -66,6 +71,7 @@ class TrackDayDetailController extends BaseController {
         _isExpanded.value = true;
         _isHideExpand.value = true;
       }
+      _dealPieChatData();
       _dealTrackExpandData();
     }).catchError((error) {
       CustomLoadingDialog.hide();
@@ -74,6 +80,44 @@ class TrackDayDetailController extends BaseController {
     });
   }
 
+  void _dealPieChatData() {
+    pieChatData.clear();
+    final list = trackDailyList;
+    if (list.isEmpty) {
+      return;
+    }
+    indexPieChatColor = 0;
+    int totalDuration = 0;
+    final Map<String, PieChatData> addrMap = {};
+    for (var bean in list) {
+      if (bean.status == TrackStatus.error) {
+        continue;
+      }
+      final addr = bean.addr;
+      if (addr == null) {
+        continue;
+      }
+      if (indexPieChatColor >= pieChatColors.length) {
+        indexPieChatColor = 0;
+      }
+      totalDuration += bean.duration;
+      if (addrMap.containsKey(addr)) {
+        addrMap[addr]!.duration += bean.duration;
+      } else {
+        addrMap[addr] = PieChatData(
+          address: addr,
+          duration: bean.duration,
+          color: pieChatColors[indexPieChatColor++],
+        );
+      }
+    }
+    for (var data in addrMap.values) {
+      data.proportion =
+          (data.duration / totalDuration * 100).toFormattedDouble(1);
+    }
+    pieChatData.addAll(addrMap.values.toList());
+  }
+
   void _dealTrackExpandData() {
     final list = trackDailyList;
     if (list.isNotEmpty && list.length > 1) {

+ 36 - 2
lib/module/track/track_day_detail/track_day_detail_view.dart

@@ -6,7 +6,7 @@ import 'package:get/get.dart';
 import 'package:get/get_core/src/get_main.dart';
 import 'package:location/base/base_view.dart';
 import 'package:location/data/bean/track_days.dart';
-import 'package:location/module/track/track_day_detail/time_proportion/time_proportion_view.dart';
+import 'package:location/module/track/track_day_detail/time_proportion/track_time_pie_chat.dart';
 import 'package:location/module/track/track_day_detail/track_daily_item.dart';
 import 'package:location/module/track/track_day_detail/track_day_detail_controller.dart';
 import 'package:location/module/track/track_status.dart';
@@ -152,7 +152,41 @@ class TrackDayDetailView extends BaseView<TrackDayDetailController> {
   }
 
   Widget buildProportionDurationView() {
-    return TimeProportionView();
+    return Column(
+      children: [
+        Container(
+          width: double.infinity,
+          height: 293.w,
+          padding: EdgeInsets.all(12.w),
+          child: Stack(
+            children: [
+              Text(StringName.trackDetailTimeProportion,
+                  style: TextStyle(
+                      fontSize: 13.sp,
+                      color: '#333333'.color,
+                      fontWeight: FontWeight.bold)),
+              _buildPieChatView()
+            ],
+          ),
+        ),
+        Container(
+          height: 8.w,
+          width: double.infinity,
+          color: '#F8F5FF'.color,
+        )
+      ],
+    );
+  }
+
+  Widget _buildPieChatView() {
+    return Obx(() {
+      if (controller.pieChatData.isEmpty) {
+        return SizedBox.shrink();
+      }
+      return Align(
+          alignment: Alignment.center,
+          child: TrackTimePieChat(pieData: controller.pieChatData));
+    });
   }
 
   Widget buildSliverHistoryTrack() {

+ 5 - 0
lib/resource/assets.gen.dart

@@ -52,6 +52,10 @@ class $AssetsImagesGen {
   AssetGenImage get bgTrackLocationTie =>
       const AssetGenImage('assets/images/bg_track_location_tie.webp');
 
+  /// File path: assets/images/bg_track_pie_chat.webp
+  AssetGenImage get bgTrackPieChat =>
+      const AssetGenImage('assets/images/bg_track_pie_chat.webp');
+
   /// File path: assets/images/bg_urgent_contact_add.webp
   AssetGenImage get bgUrgentContactAdd =>
       const AssetGenImage('assets/images/bg_urgent_contact_add.webp');
@@ -548,6 +552,7 @@ class $AssetsImagesGen {
         bgMineMemberCard,
         bgPageBackground,
         bgTrackLocationTie,
+        bgTrackPieChat,
         bgUrgentContactAdd,
         bgUrgentContactEmpty,
         bgUrgentContactLogo,