Browse Source

[new]增加行为总结

zk 5 months ago
parent
commit
c8a1ee02ee

BIN
assets/images/icon_call_phone.webp


BIN
assets/images/icon_track_daily_summary.webp


BIN
assets/images/icon_track_daily_summary_arrow.webp


+ 8 - 0
assets/string/base/string.xml

@@ -337,4 +337,12 @@
         若对方卸载app,轨迹行程待TA重新下载恢复定位后,将会重新展示TA行程
     </string>
     <string name="dialog_track_error_btn_txt">我知道了</string>
+    <string name="track_detail_daily_summary">行为总结</string>
+    <string name="track_detail_daily_summary_share">分享报告</string>
+    <string name="track_detail_daily_summary_get_fail">行为总结获取失败</string>
+    <string name="track_daily_summary_phone">手机情况</string>
+    <string name="track_daily_summary_stay">停留最长</string>
+    <string name="track_daily_summary_track">轨迹情况</string>
+    <string name="track_daily_call_phone">联系TA</string>
+    <string name="track_daily_skip_call_phone_fail">跳转拨号界面失败</string>
 </resources>

+ 11 - 8
lib/data/api/atmob_api.dart

@@ -1,5 +1,3 @@
-import 'dart:convert';
-
 import 'package:dio/dio.dart';
 import 'package:location/base/app_base_request.dart';
 import 'package:location/base/base_response.dart';
@@ -40,6 +38,7 @@ import 'package:location/data/api/response/request_pay_response.dart';
 import 'package:location/data/api/response/subscription_check_response.dart';
 import 'package:location/data/api/response/track_daily_dialogs_response.dart';
 import 'package:location/data/api/response/track_daily_response.dart';
+import 'package:location/data/api/response/track_daily_summary_response.dart';
 import 'package:location/data/api/response/user_avatar_response.dart';
 import 'package:retrofit/error_logger.dart';
 import 'package:retrofit/http.dart';
@@ -157,8 +156,7 @@ abstract class AtmobApi {
   Future<BaseResponse> userClear(@Body() AppBaseRequest request);
 
   @POST("/s/v1/member/trial")
-  Future<BaseResponse> memberTrial(
-      @Body() AppBaseRequest request);
+  Future<BaseResponse> memberTrial(@Body() AppBaseRequest request);
 
   @POST("/s/v1/item/list")
   Future<BaseResponse<ItemListResponse>> getMemberList(
@@ -182,11 +180,13 @@ abstract class AtmobApi {
 
   ///恢复订阅
   @POST("/s/v1/subscription/resume")
-  Future<BaseResponse> subscriptionresume(@Body() SubscriptionResumeRequest request);
+  Future<BaseResponse> subscriptionresume(
+      @Body() SubscriptionResumeRequest request);
 
   ///试用结束查看试用信息-
   @POST("/s/v1/member/trial/info")
-  Future<BaseResponse <MemberTrialInfoResponse>> memberTrailInfo(@Body() AppBaseRequest request);
+  Future<BaseResponse<MemberTrialInfoResponse>> memberTrailInfo(
+      @Body() AppBaseRequest request);
 
   ///试用期间上报查看轨迹次数-
   @POST("/s/v1/member/trial/track")
@@ -207,8 +207,7 @@ abstract class AtmobApi {
 
   ///检查是否是首次购买-中台
   @POST("/s/v1/member/evaluate")
-  Future<BaseResponse> memberEvaluate(
-      @Body() AppBaseRequest request);
+  Future<BaseResponse> memberEvaluate(@Body() AppBaseRequest request);
 
   @POST("/s/v1/location/track/days")
   Future<BaseResponse<LocationTrackDaysResponse>> locationTrackDays(
@@ -218,6 +217,10 @@ abstract class AtmobApi {
   Future<BaseResponse<TrackDailyResponse>> trackDailyQuery(
       @Body() QueryTrackRequest request, @DioOptions() RequestOptions options);
 
+  @POST("/s/v1/location/track/daily/summary")
+  Future<BaseResponse<TrackDailySummaryResponse>> trackDailySummary(
+      @Body() QueryTrackRequest request, @DioOptions() RequestOptions options);
+
   //上报推送信息-中台
   @POST("/central/notification/v1/notification/report")
   Future<BaseResponse> notificationReport(

+ 84 - 0
lib/data/api/atmob_stream_api.c.dart

@@ -0,0 +1,84 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'atmob_stream_api.dart';
+
+// **************************************************************************
+// RetrofitGenerator
+// **************************************************************************
+
+// ignore_for_file: unnecessary_brace_in_string_interps,no_leading_underscores_for_local_identifiers,unused_element,unnecessary_string_interpolations
+
+class _AtmobStreamApi implements AtmobStreamApi {
+  _AtmobStreamApi(
+    this._dio, {
+    this.baseUrl,
+    this.errorLogger,
+  });
+
+  final Dio _dio;
+
+  String? baseUrl;
+
+  final ParseErrorLogger? errorLogger;
+
+  @override
+  Future<ResponseBody> dailySummary(QueryTrackRequest request) async {
+    const _extra = <String, dynamic>{};
+    final queryParameters = <String, dynamic>{};
+    final _headers = <String, dynamic>{
+      r'Content-Type': 'application/json',
+      r'Accept': 'text/event-stream'
+    };
+    final _data = <String, dynamic>{};
+    _data.addAll(request.toJson());
+    final _result =
+        await _dio.fetch<ResponseBody>(_setStreamType<ResponseBody>(Options(
+      method: 'POST',
+      headers: _headers,
+      extra: _extra,
+      responseType: ResponseType.stream,
+    )
+            .compose(
+              _dio.options,
+              '/s/v1/chat/daily/summary',
+              queryParameters: queryParameters,
+              data: _data,
+            )
+            .copyWith(
+                baseUrl: _combineBaseUrls(
+              _dio.options.baseUrl,
+              baseUrl,
+            ))));
+    return _result.data!;
+  }
+
+  RequestOptions _setStreamType<T>(RequestOptions requestOptions) {
+    if (T != dynamic &&
+        !(requestOptions.responseType == ResponseType.bytes ||
+            requestOptions.responseType == ResponseType.stream)) {
+      if (T == String) {
+        requestOptions.responseType = ResponseType.plain;
+      } else {
+        requestOptions.responseType = ResponseType.json;
+      }
+    }
+    return requestOptions;
+  }
+
+  String _combineBaseUrls(
+    String dioBaseUrl,
+    String? baseUrl,
+  ) {
+    if (baseUrl == null || baseUrl.trim().isEmpty) {
+      return dioBaseUrl;
+    }
+
+    final url = Uri.parse(baseUrl);
+
+    if (url.isAbsolute) {
+      return url.toString();
+    }
+
+    return Uri.parse(dioBaseUrl).resolveUri(url).toString();
+  }
+}

+ 15 - 0
lib/data/api/atmob_stream_api.dart

@@ -0,0 +1,15 @@
+import 'package:dio/dio.dart' hide Headers;
+import 'package:dio/dio.dart';
+import 'package:location/data/api/request/query_track_request.dart';
+import 'package:retrofit/retrofit.dart';
+
+part 'atmob_stream_api.c.dart';
+
+abstract class AtmobStreamApi {
+  factory AtmobStreamApi(Dio dio,
+      {String baseUrl, ParseErrorLogger? errorLogger}) = _AtmobStreamApi;
+
+  @POST("/s/v1/chat/daily/summary")
+  @DioResponseType(ResponseType.stream)
+  Future<ResponseBody> dailySummary(@Body() QueryTrackRequest request);
+}

+ 30 - 0
lib/data/api/response/track_daily_summary_response.dart

@@ -0,0 +1,30 @@
+import 'package:json_annotation/json_annotation.dart';
+
+import '../../bean/track_summary.dart';
+
+part 'track_daily_summary_response.g.dart';
+
+@JsonSerializable()
+class TrackDailySummaryResponse {
+  @JsonKey(name: 'phoneSituation')
+  TrackSummary? phoneSituation;
+
+  @JsonKey(name: 'stayLongest')
+  TrackSummary? stayLongest;
+
+  @JsonKey(name: 'trackSituation')
+  String? trackSituation;
+
+  @JsonKey(name: 'showTrackSituation')
+  bool showTrackSituation;
+
+  TrackDailySummaryResponse({
+    this.phoneSituation,
+    this.stayLongest,
+    this.trackSituation,
+    required this.showTrackSituation,
+  });
+
+  factory TrackDailySummaryResponse.fromJson(Map<String, dynamic> json) =>
+      _$TrackDailySummaryResponseFromJson(json);
+}

+ 30 - 0
lib/data/api/response/track_daily_summary_response.g.dart

@@ -0,0 +1,30 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'track_daily_summary_response.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+TrackDailySummaryResponse _$TrackDailySummaryResponseFromJson(
+        Map<String, dynamic> json) =>
+    TrackDailySummaryResponse(
+      phoneSituation: json['phoneSituation'] == null
+          ? null
+          : TrackSummary.fromJson(
+              json['phoneSituation'] as Map<String, dynamic>),
+      stayLongest: json['stayLongest'] == null
+          ? null
+          : TrackSummary.fromJson(json['stayLongest'] as Map<String, dynamic>),
+      trackSituation: json['trackSituation'] as String?,
+      showTrackSituation: json['showTrackSituation'] as bool,
+    );
+
+Map<String, dynamic> _$TrackDailySummaryResponseToJson(
+        TrackDailySummaryResponse instance) =>
+    <String, dynamic>{
+      'phoneSituation': instance.phoneSituation,
+      'stayLongest': instance.stayLongest,
+      'trackSituation': instance.trackSituation,
+      'showTrackSituation': instance.showTrackSituation,
+    };

+ 54 - 0
lib/data/bean/stream_chat_origin_data.dart

@@ -0,0 +1,54 @@
+/*
+ * {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o-mini", "system_fingerprint": "fp_44709d6fcb", "choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}]}
+ */
+
+import 'package:json_annotation/json_annotation.dart';
+
+part 'stream_chat_origin_data.g.dart';
+
+@JsonSerializable()
+class StreamChatOriginData {
+  @JsonKey(name: "choices")
+  final List<Choices>? choices;
+
+  StreamChatOriginData({
+    required this.choices,
+  });
+
+  factory StreamChatOriginData.fromJson(Map<String, dynamic> json) =>
+      _$StreamChatOriginDataFromJson(json);
+}
+
+@JsonSerializable()
+class Choices {
+  @JsonKey(name: "index")
+  final int? index;
+  @JsonKey(name: "delta")
+  final Delta? delta;
+  @JsonKey(name: "finishReason")
+  final String? finishReason;
+
+  Choices({
+    required this.index,
+    required this.delta,
+    required this.finishReason,
+  });
+
+  factory Choices.fromJson(Map<String, dynamic> json) =>
+      _$ChoicesFromJson(json);
+}
+
+@JsonSerializable()
+class Delta {
+  @JsonKey(name: "role")
+  final String? role;
+  @JsonKey(name: "content")
+  final String? content;
+
+  Delta({
+    required this.role,
+    required this.content,
+  });
+
+  factory Delta.fromJson(Map<String, dynamic> json) => _$DeltaFromJson(json);
+}

+ 45 - 0
lib/data/bean/stream_chat_origin_data.g.dart

@@ -0,0 +1,45 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'stream_chat_origin_data.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+StreamChatOriginData _$StreamChatOriginDataFromJson(
+        Map<String, dynamic> json) =>
+    StreamChatOriginData(
+      choices: (json['choices'] as List<dynamic>?)
+          ?.map((e) => Choices.fromJson(e as Map<String, dynamic>))
+          .toList(),
+    );
+
+Map<String, dynamic> _$StreamChatOriginDataToJson(
+        StreamChatOriginData instance) =>
+    <String, dynamic>{
+      'choices': instance.choices,
+    };
+
+Choices _$ChoicesFromJson(Map<String, dynamic> json) => Choices(
+      index: (json['index'] as num?)?.toInt(),
+      delta: json['delta'] == null
+          ? null
+          : Delta.fromJson(json['delta'] as Map<String, dynamic>),
+      finishReason: json['finishReason'] as String?,
+    );
+
+Map<String, dynamic> _$ChoicesToJson(Choices instance) => <String, dynamic>{
+      'index': instance.index,
+      'delta': instance.delta,
+      'finishReason': instance.finishReason,
+    };
+
+Delta _$DeltaFromJson(Map<String, dynamic> json) => Delta(
+      role: json['role'] as String?,
+      content: json['content'] as String?,
+    );
+
+Map<String, dynamic> _$DeltaToJson(Delta instance) => <String, dynamic>{
+      'role': instance.role,
+      'content': instance.content,
+    };

+ 35 - 0
lib/data/bean/track_summary.dart

@@ -0,0 +1,35 @@
+import 'package:json_annotation/json_annotation.dart';
+
+@JsonSerializable()
+class TrackSummary {
+  @JsonKey(name: 'text')
+  String text;
+
+  @JsonKey(name: 'items', includeToJson: false)
+  Map<String, String>? items;
+
+  TrackSummary({
+    required this.text,
+    this.items,
+  });
+
+  factory TrackSummary.fromJson(Map<String, dynamic> json) {
+    final rawItems = json['items'] as List<dynamic>? ?? [];
+
+    final parsedItems = <String, String>{};
+    for (final item in rawItems) {
+      if (item is Map<String, dynamic>) {
+        final key = item['key']?.toString();
+        final value = item['value']?.toString();
+        if (key != null && value != null) {
+          parsedItems[key] = value;
+        }
+      }
+    }
+
+    return TrackSummary(
+      text: json['text'] ?? '',
+      items: parsedItems,
+    );
+  }
+}

+ 49 - 5
lib/data/repositories/track_repository.dart

@@ -1,26 +1,28 @@
-import 'dart:ffi';
-
+import 'dart:convert';
 import 'package:dio/dio.dart';
 import 'package:injectable/injectable.dart';
 import 'package:location/base/app_base_request.dart';
 import 'package:location/data/api/atmob_api.dart';
-
+import 'package:location/data/api/atmob_stream_api.dart';
+import '../../base/base_response.dart';
 import '../../di/get_it.dart';
 import '../../utils/http_handler.dart';
+import '../../utils/sse_parse_util.dart';
 import '../api/request/query_track_request.dart';
 import '../api/response/location_track_days_response.dart';
 import '../api/response/query_track_response.dart';
-import '../api/response/track_daily_dialogs_response.dart';
+import '../api/response/track_daily_summary_response.dart';
 import '../bean/track_daily_bean.dart';
 import '../bean/track_days.dart';
 
 @lazySingleton
 class TrackRepository {
   final AtmobApi atmobApi;
+  final AtmobStreamApi streamApi;
 
   List<TrackDays>? days;
 
-  TrackRepository(this.atmobApi);
+  TrackRepository(this.atmobApi, this.streamApi);
 
   static TrackRepository getInstance() {
     return getIt.get<TrackRepository>();
@@ -99,4 +101,46 @@ class TrackRepository {
     });
   }
 
+
+  Future<TrackDailySummaryResponse> trackDailySummary(
+      {required int? startTime,
+      required int? endTime,
+      required String? userId}) {
+    return atmobApi
+        .trackDailySummary(
+            QueryTrackRequest(
+                startTime: startTime, endTime: endTime, userId: userId),
+            RequestOptions(
+                receiveTimeout: Duration(seconds: 30),
+                connectTimeout: Duration(minutes: 2)))
+        .then(HttpHandler.handle(true));
+  }
+
+  Future<Stream<Message>> streamDailySummary(
+      {required int? startTime,
+      required int? endTime,
+      required String? userId}) {
+    return streamApi
+        .dailySummary(QueryTrackRequest(
+            startTime: startTime, endTime: endTime, userId: userId))
+        .then((response) async {
+      List<String>? contentType = response.headers['Content-Type'];
+      if (contentType != null) {
+        for (var value in contentType) {
+          if (value.contains('text/event-stream')) {
+            return response.stream;
+          } else if (value.contains('application/json')) {
+            BaseResponse<String> baseResponse = BaseResponse.fromJson(
+                jsonDecode(await response.stream
+                    .map((bytes) => utf8.decoder.convert(bytes))
+                    .toList()
+                    .then((value) => value.join())),
+                (json) => json as String);
+            throw ServerErrorException(baseResponse.code, baseResponse.message);
+          }
+        }
+      }
+      throw Exception('Invalid content type');
+    }).then((stream) => SSEParseUtil.parse(stream));
+  }
 }

+ 17 - 0
lib/di/network_module.dart

@@ -1,8 +1,10 @@
 import 'package:dio/dio.dart';
 import 'package:injectable/injectable.dart';
 import 'package:location/data/api/atmob_api.dart';
+import 'package:location/data/api/atmob_stream_api.dart';
 import 'package:location/data/consts/constants.dart';
 import 'package:pretty_dio_logger/pretty_dio_logger.dart';
+import '../data/api/interceptor/stream_dio_log_interceptor.dart';
 import '../data/consts/build_config.dart';
 
 @module
@@ -22,8 +24,23 @@ abstract class NetworkModule {
     return dio;
   }
 
+  @Named('stream')
+  @singleton
+  Dio createStreamDio() {
+    Dio streamDio = Dio(BaseOptions(
+      responseType: ResponseType.stream,
+    ));
+    streamDio.interceptors.add(StreamDioLogInterceptor());
+    return streamDio;
+  }
+
   @singleton
   AtmobApi provideAtmobApi(Dio dio) {
     return AtmobApi(dio, baseUrl: Constants.baseUrl);
   }
+
+  @singleton
+  AtmobStreamApi provideAtmobStreamApi(@Named('stream') Dio dio) {
+    return AtmobStreamApi(dio, baseUrl: Constants.baseUrl);
+  }
 }

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

@@ -1,177 +0,0 @@
-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});
-}

+ 129 - 1
lib/module/track/track_day_detail/track_day_detail_controller.dart

@@ -1,3 +1,7 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:flutter/cupertino.dart';
 import 'package:get/get.dart';
 import 'package:get/get_core/src/get_main.dart';
 import 'package:location/base/base_controller.dart';
@@ -8,10 +12,17 @@ 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/async_util.dart';
+import 'package:location/utils/atmob_log.dart';
 import 'package:location/utils/common_expand.dart';
 import 'package:location/utils/pair.dart';
-
+import 'package:location/utils/toast_util.dart';
+import 'package:url_launcher/url_launcher.dart';
+import '../../../data/api/response/track_daily_summary_response.dart';
+import '../../../data/bean/stream_chat_origin_data.dart';
 import '../../../data/bean/track_days.dart';
+import '../../../utils/http_handler.dart';
+import '../../../widget/gradually_print_text.dart';
 import '../track_status.dart';
 
 class TrackDayDetailController extends BaseController {
@@ -43,6 +54,23 @@ class TrackDayDetailController extends BaseController {
 
   final TrackController trackController = Get.find<TrackController>();
 
+  final RxBool _isShowGradually = RxBool(false);
+
+  bool get isShowGradually => _isShowGradually.value;
+
+  final Rxn<TrackDailySummaryResponse> _trackDailySummary = Rxn();
+
+  TrackDailySummaryResponse? get trackDailySummary => _trackDailySummary.value;
+
+  final GraduallyController graduallyController = GraduallyController();
+  StreamSubscription? _streamChatSubscription;
+
+  final RxnString _summaryError = RxnString();
+
+  String? get summaryError => _summaryError.value;
+
+  CancelableFuture? summaryFuture;
+
   TrackDayDetailController(this.days, bool isExpand) {
     trackRepository = TrackRepository.getInstance();
     _isExpanded.value = isExpand;
@@ -52,6 +80,33 @@ class TrackDayDetailController extends BaseController {
   void onInit() {
     super.onInit();
     _requestTrackDaily();
+    _requestTrackDailySummary();
+    _requestTrackHistoryPoints();
+  }
+
+  void _requestTrackHistoryPoints() {}
+
+  void _requestTrackDailySummary() {
+    summaryFuture?.cancel();
+    summaryFuture = AsyncUtil.retry(
+        () => _requestTrackSummary(), Duration(seconds: 2),
+        maxRetry: 5);
+
+    summaryFuture!.catchError((error) {
+      ToastUtil.show(StringName.trackDetailDailySummaryGetFail);
+    });
+  }
+
+  Future<TrackDailySummaryResponse> _requestTrackSummary() {
+    return trackRepository
+        .trackDailySummary(
+            startTime: days.start,
+            endTime: days.end,
+            userId: trackController.userInfo?.id)
+        .then((response) {
+      _trackDailySummary.value = response;
+      return response;
+    });
   }
 
   void _requestTrackDaily() {
@@ -153,4 +208,77 @@ class TrackDayDetailController extends BaseController {
   void onTrackDetailFoldClick() {
     _isExpanded.value = !_isExpanded.value;
   }
+
+  void checkGraduallyPrintTextVisible(double visibleFraction) {
+    if (!isShowGradually && visibleFraction >= 0.3) {
+      _isShowGradually.value = true;
+      //流式获取轨迹情况
+      _requestStreamTrackDaily();
+    }
+  }
+
+  void _requestStreamTrackDaily() {
+    graduallyController.dispose();
+    _streamChatSubscription?.cancel();
+    trackRepository
+        .streamDailySummary(
+            startTime: days.start,
+            endTime: days.end,
+            userId: trackController.userInfo?.id)
+        .then((stream) {
+      //
+      graduallyController.setGraduallyFinishedListener(() {
+        _trackDailySummary.value?.trackSituation =
+            graduallyController.graduallyTxt;
+        _trackDailySummary.refresh();
+      });
+      _streamChatSubscription = stream.listen((event) {
+        try {
+          Map<String, dynamic> json = jsonDecode(event.data);
+          if (json.isEmpty) {
+            return;
+          }
+          StreamChatOriginData data = StreamChatOriginData.fromJson(json);
+          if (data.choices == null || data.choices!.isEmpty) {
+            return;
+          }
+          Delta? delta = data.choices![0].delta;
+          if (delta == null) {
+            return;
+          }
+          graduallyController.append(delta.content ?? "");
+        } catch (ignore) {}
+      }, onDone: () {
+        graduallyController.appendDone();
+      }, onError: (error) {
+        _summaryError.value = "网络错误,请检查网络连接";
+        debugPrint("error: $error");
+        debugPrintStack();
+      });
+    }).catchError((error) {
+      if (error is ServerErrorException) {
+        _summaryError.value = error.message ?? "服务出错,请稍后再试";
+      } else {
+        _summaryError.value = "网络错误,请检查网络连接";
+        debugPrint("error: $error");
+        debugPrintStack();
+      }
+    });
+  }
+
+  void onPhoneCallClick() async {
+    final Uri uri = Uri.parse('tel:${trackController.userInfo?.phoneNumber}');
+    if (await canLaunchUrl(uri)) {
+      await launchUrl(uri);
+    } else {
+      ToastUtil.show(StringName.trackDailySkipCallPhoneFail);
+    }
+  }
+
+  @override
+  void onClose() {
+    super.onClose();
+    _streamChatSubscription?.cancel();
+    graduallyController.dispose();
+  }
 }

+ 194 - 1
lib/module/track/track_day_detail/track_day_detail_view.dart

@@ -13,13 +13,20 @@ import 'package:location/module/track/track_status.dart';
 import 'package:location/resource/assets.gen.dart';
 import 'package:location/resource/colors.gen.dart';
 import 'package:location/resource/string.gen.dart';
+import 'package:location/utils/atmob_log.dart';
 import 'package:location/utils/common_expand.dart';
+import 'package:location/utils/toast_util.dart';
+import 'package:location/widget/rich_text_replace.dart';
+import 'package:visibility_detector/visibility_detector.dart';
+import '../../../data/bean/track_summary.dart';
+import '../../../widget/drop_cap_text.dart';
+import '../../../widget/gradually_print_text.dart';
 
 class TrackDayDetailView extends BaseView<TrackDayDetailController> {
   late final String trackTag;
 
   TrackDayDetailView(TrackDays days, {super.key, bool isExpand = false}) {
-    trackTag = days.day;
+    trackTag = 'TrackDayDetailView_${days.day}';
     Get.lazyPut(() => TrackDayDetailController(days, isExpand),
         tag: trackTag, fenix: true);
   }
@@ -60,6 +67,9 @@ class TrackDayDetailView extends BaseView<TrackDayDetailController> {
             )),
             SliverToBoxAdapter(
               child: buildProportionDurationView(),
+            ),
+            SliverToBoxAdapter(
+              child: buildTrackDailySummaryView(),
             )
           ],
         ),
@@ -68,6 +78,189 @@ class TrackDayDetailView extends BaseView<TrackDayDetailController> {
     );
   }
 
+  Widget buildTrackDailySummaryView() {
+    return Column(
+      children: [
+        buildDailySummaryTitle(),
+        SizedBox(height: 8.w),
+        Obx(() {
+          return _buildSituationItem(StringName.trackDailySummaryPhone,
+              controller.trackDailySummary?.phoneSituation);
+        }),
+        Obx(() {
+          return _buildSituationItem(StringName.trackDailySummaryStay,
+              controller.trackDailySummary?.stayLongest);
+        }),
+        //轨迹情况
+        Obx(() {
+          return _buildDailyTrack(
+              controller.trackDailySummary?.showTrackSituation,
+              controller.trackDailySummary?.trackSituation);
+        }),
+        SizedBox(height: 100.w)
+      ],
+    );
+  }
+
+  Widget _buildDailyTrack(bool? isShow, String? trackSituation) {
+    if (isShow == false) {
+      return SizedBox.shrink();
+    }
+    if (trackSituation != null && trackSituation.isNotEmpty) {
+      return _buildDailyDoneSituationView(trackSituation);
+    }
+    return _buildDailyTrackPrintingView();
+  }
+
+  Widget _buildDailyDoneSituationView(String trackSituation) {
+    return Container(
+      width: double.infinity,
+      padding: EdgeInsets.symmetric(horizontal: 22.w),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Text(StringName.trackDailySummarytrack,
+              style: TextStyle(
+                  fontSize: 13.sp,
+                  color: '#333333'.color,
+                  fontWeight: FontWeight.bold)),
+          SizedBox(height: 8.w),
+          DropCapText(
+            style: TextStyle(fontSize: 11.sp, color: '#666666'.color),
+            trackSituation,
+            dropCapPosition: DropCapPosition.bottomRight,
+            textAlign: TextAlign.justify,
+            dropCap: DropCap(
+                width: 75.w,
+                height: 36.w,
+                child: Align(
+                  alignment: Alignment.bottomRight,
+                  child: GestureDetector(
+                    onTap: controller.onPhoneCallClick,
+                    child: Column(
+                      crossAxisAlignment: CrossAxisAlignment.center,
+                      mainAxisAlignment: MainAxisAlignment.end,
+                      children: [
+                        Assets.images.iconCallPhone
+                            .image(width: 15.w, height: 15.w),
+                        Text(
+                          StringName.trackDailyCallPhone,
+                          style: TextStyle(
+                              fontSize: 10.sp,
+                              color: ColorName.colorPrimary,
+                              fontWeight: FontWeight.bold),
+                        )
+                      ],
+                    ),
+                  ),
+                )),
+          ),
+          SizedBox(height: 12.w),
+        ],
+      ),
+    );
+  }
+
+  Widget _buildDailyTrackPrintingView() {
+    return VisibilityDetector(
+      key: Key('track-summary-print'),
+      onVisibilityChanged: (VisibilityInfo info) {
+        final visibleFraction = info.visibleFraction;
+        controller.checkGraduallyPrintTextVisible(visibleFraction);
+      },
+      child: Container(
+        width: double.infinity,
+        padding: EdgeInsets.symmetric(horizontal: 22.w),
+        child: Column(
+          crossAxisAlignment: CrossAxisAlignment.start,
+          children: [
+            Text(StringName.trackDailySummarytrack,
+                style: TextStyle(
+                    fontSize: 13.sp,
+                    color: '#333333'.color,
+                    fontWeight: FontWeight.bold)),
+            SizedBox(height: 8.w),
+            Obx(() {
+              if (controller.summaryError?.isNotEmpty == true) {
+                return Text(controller.summaryError!,
+                    style: TextStyle(fontSize: 11.sp, color: '#FF0000'.color));
+              } else {
+                return GraduallyPrintText(
+                  graduallyController: controller.graduallyController,
+                  textStyle: TextStyle(fontSize: 11.sp, color: '#666666'.color),
+                );
+              }
+            }),
+            SizedBox(height: 12.w),
+          ],
+        ),
+      ),
+    );
+  }
+
+  Widget buildDailySummaryTitle() {
+    return Row(
+      children: [
+        SizedBox(width: 22.w),
+        Assets.images.iconTrackDailySummary.image(width: 20.w),
+        SizedBox(width: 5.w),
+        Text(StringName.trackDetailDailySummary,
+            style: TextStyle(
+                fontWeight: FontWeight.bold,
+                fontSize: 14.sp,
+                color: '#333333'.color)),
+        Spacer(),
+        Container(
+          padding: EdgeInsets.symmetric(horizontal: 5.w, vertical: 12.w),
+          child: Row(
+            children: [
+              Text(StringName.trackDetailDailySummaryShare,
+                  style: TextStyle(fontSize: 12.sp, color: '#666666'.color)),
+              Assets.images.iconTrackDailySummaryArrow.image(height: 11.w)
+            ],
+          ),
+        ),
+        SizedBox(width: 10.w),
+      ],
+    );
+  }
+
+  Widget _buildSituationItem(String title, TrackSummary? summary) {
+    if (summary == null) {
+      return SizedBox.shrink();
+    }
+    return Container(
+      width: double.infinity,
+      padding: EdgeInsets.symmetric(horizontal: 22.w),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Text(title,
+              style: TextStyle(
+                  fontSize: 13.sp,
+                  color: '#333333'.color,
+                  fontWeight: FontWeight.bold)),
+          SizedBox(height: 8.w),
+          RichTextReplace(
+              text: summary.text,
+              items: summary.items,
+              defaultStyle: TextStyle(fontSize: 11.sp, color: '#666666'.color),
+              replacedStyle: TextStyle(
+                  fontWeight: FontWeight.bold,
+                  fontSize: 11.sp,
+                  color: '#333333'.color)),
+          SizedBox(height: 12.w),
+          Container(
+            width: double.infinity,
+            height: 1.w,
+            color: '#EEEEEE'.color,
+          ),
+          SizedBox(height: 10.w),
+        ],
+      ),
+    );
+  }
+
   Widget _buildFoldContentView() {
     return Obx(() {
       return Column(

+ 142 - 0
lib/utils/sse_parse_util.dart

@@ -0,0 +1,142 @@
+import 'dart:async';
+import 'dart:convert';
+import 'dart:typed_data';
+
+class SSEParseUtil {
+  static Stream<Message> parse(Stream<Uint8List> stream) {
+    return stream.transform(SSETransformer());
+  }
+}
+
+class Message {
+  final String id;
+  final String event;
+  final String data;
+  final int? retry;
+
+  Message(
+      {required this.id,
+      required this.event,
+      required this.data,
+      required this.retry});
+
+  @override
+  String toString() {
+    return 'Message{id: $id, event: $event, data: $data, retry: $retry}';
+  }
+}
+
+class SSETransformer extends StreamTransformerBase<Uint8List, Message> {
+  @override
+  Stream<Message> bind(Stream<Uint8List> stream) {
+    return Stream.eventTransformed(
+      stream.map((uint8List) => List<int>.from(uint8List)),
+      (sink) => SSESink(sink),
+    );
+  }
+}
+
+class SSESink implements EventSink<List<int>> {
+  static final _eventSeparator = utf8.encode("\n\n");
+  static const _fieldSeparator = "\n";
+  static const _dataPrefix = "data:";
+  static const _dataPrefixR = "data: ";
+  static const _idPrefix = "id:";
+  static const _idPrefixR = "id: ";
+  static const _eventPrefix = "event:";
+  static const _eventPrefixR = "event: ";
+  static const _retryPrefix = "retry:";
+  static const _retryPrefixR = "retry: ";
+  static const _commentPrefix = ":";
+  static const _commentPrefixR = ": ";
+
+  final EventSink<Message> _eventSink;
+
+  final List<int> _buffer = [];
+
+  SSESink(this._eventSink);
+
+  @override
+  void add(List<int> event) {
+    _buffer.addAll(event);
+
+    while (true) {
+      final endIndex = _indexOf(_buffer, _eventSeparator);
+      if (endIndex == -1) {
+        break;
+      }
+
+      final completedEvent = _buffer.sublist(0, endIndex);
+      _buffer.removeRange(0, endIndex + _eventSeparator.length);
+      parseEvent(completedEvent);
+    }
+  }
+
+  @override
+  void addError(Object error, [StackTrace? stackTrace]) {
+    _eventSink.addError(error, stackTrace);
+  }
+
+  @override
+  void close() {
+    _eventSink.close();
+  }
+
+  int _indexOf(List<int> origin, List<int> target) {
+    for (var i = 0; i < origin.length - target.length; i++) {
+      var found = true;
+      for (var j = 0; j < target.length; j++) {
+        if (origin[i + j] != target[j]) {
+          found = false;
+          break;
+        }
+      }
+      if (found) {
+        return i;
+      }
+    }
+    return -1;
+  }
+
+  void parseEvent(List<int> completedEvent) {
+    final eventString = utf8.decode(completedEvent);
+    final fields = eventString.split(_fieldSeparator);
+    String? id;
+    String? event;
+    String data = "";
+    int? retry;
+
+    for (final field in fields) {
+      final trimmedField = field.trim();
+      if (trimmedField.isEmpty) {
+        continue;
+      }
+
+      if (trimmedField.startsWith(_commentPrefix) ||
+          trimmedField.startsWith(_commentPrefixR)) {
+        continue;
+      }
+
+      if (trimmedField.startsWith(_retryPrefixR)) {
+        retry = int.tryParse(trimmedField.substring(_retryPrefixR.length));
+      } else if (trimmedField.startsWith(_dataPrefixR)) {
+        data += trimmedField.substring(_dataPrefixR.length);
+      } else if (trimmedField.startsWith(_eventPrefixR)) {
+        event = trimmedField.substring(_eventPrefixR.length);
+      } else if (trimmedField.startsWith(_idPrefixR)) {
+        id = trimmedField.substring(_idPrefixR.length);
+      } else if (trimmedField.startsWith(_idPrefix)) {
+        id = trimmedField.substring(_idPrefix.length);
+      } else if (trimmedField.startsWith(_eventPrefix)) {
+        event = trimmedField.substring(_eventPrefix.length);
+      } else if (trimmedField.startsWith(_dataPrefix)) {
+        data += trimmedField.substring(_dataPrefix.length);
+      } else if (trimmedField.startsWith(_retryPrefix)) {
+        retry = int.tryParse(trimmedField.substring(_retryPrefix.length));
+      }
+    }
+
+    _eventSink.add(
+        Message(id: id ?? "", event: event ?? "", data: data, retry: retry));
+  }
+}

+ 285 - 0
lib/widget/drop_cap_text.dart

@@ -0,0 +1,285 @@
+// 完整 DropCapText 组件,支持 DropCapPosition.bottomRight,支持 \n 换行符自动避让右下角图像
+
+import 'dart:math';
+import 'package:flutter/material.dart';
+
+enum DropCapMode {
+  inside,
+  upwards,
+  aside,
+  baseline,
+}
+
+enum DropCapPosition {
+  start,
+  end,
+  bottomRight,
+}
+
+class DropCap extends StatelessWidget {
+  final Widget child;
+  final double width, height;
+
+  const DropCap(
+      {super.key,
+      required this.child,
+      required this.width,
+      required this.height});
+
+  @override
+  Widget build(BuildContext context) =>
+      SizedBox(width: width, height: height, child: child);
+}
+
+class DropCapText extends StatelessWidget {
+  final String data;
+  final DropCapMode mode;
+  final TextStyle? style, dropCapStyle;
+  final TextAlign textAlign;
+  final DropCap? dropCap;
+  final EdgeInsets dropCapPadding;
+  final Offset indentation;
+  final bool forceNoDescent, parseInlineMarkdown;
+  final TextDirection textDirection;
+  final DropCapPosition? dropCapPosition;
+  final int dropCapChars;
+  final int? maxLines;
+  final TextOverflow overflow;
+
+  const DropCapText(
+    this.data, {
+    super.key,
+    this.mode = DropCapMode.inside,
+    this.style,
+    this.dropCapStyle,
+    this.textAlign = TextAlign.start,
+    this.dropCap,
+    this.dropCapPadding = EdgeInsets.zero,
+    this.indentation = Offset.zero,
+    this.dropCapChars = 1,
+    this.forceNoDescent = false,
+    this.parseInlineMarkdown = false,
+    this.textDirection = TextDirection.ltr,
+    this.overflow = TextOverflow.clip,
+    this.maxLines,
+    this.dropCapPosition,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    final textStyle = const TextStyle(fontSize: 14, height: 1.3).merge(style);
+    final capStyle = TextStyle(
+      color: textStyle.color,
+      fontSize: textStyle.fontSize! * 5.5,
+      fontFamily: textStyle.fontFamily,
+      fontWeight: textStyle.fontWeight,
+      fontStyle: textStyle.fontStyle,
+      height: 1,
+    ).merge(dropCapStyle);
+
+    final mdData = parseInlineMarkdown ? MarkdownParser(data) : null;
+    final dropCapStr = (mdData?.plainText ?? data)
+        .substring(0, dropCap != null ? 0 : dropCapChars);
+    final mdRest = parseInlineMarkdown ? mdData!.subchars(dropCapChars) : null;
+    final restData = data.substring(dropCap != null ? 0 : dropCapChars);
+
+    double capWidth, capHeight;
+    if (dropCap != null) {
+      capWidth = dropCap!.width;
+      capHeight = dropCap!.height;
+    } else {
+      final capPainter = TextPainter(
+        text: TextSpan(text: dropCapStr, style: capStyle),
+        textDirection: textDirection,
+      )..layout();
+      capWidth = capPainter.width;
+      capHeight = capPainter.height;
+      if (forceNoDescent) {
+        final metrics = capPainter.computeLineMetrics();
+        if (metrics.isNotEmpty) capHeight -= metrics[0].descent * 0.95;
+      }
+    }
+
+    capWidth += dropCapPadding.horizontal;
+    capHeight += dropCapPadding.vertical;
+
+    return LayoutBuilder(builder: (context, constraints) {
+      final restParagraphs = restData.split('\n');
+      final children = <Widget>[];
+
+      for (int p = 0; p < restParagraphs.length; p++) {
+        final para = restParagraphs[p];
+        final span = TextSpan(text: para, style: textStyle);
+        final tp = TextPainter(
+          text: span,
+          textDirection: textDirection,
+          textAlign: textAlign,
+        )..layout(maxWidth: constraints.maxWidth);
+
+        final lines = tp.computeLineMetrics();
+        final lineHeight = tp.preferredLineHeight;
+
+        if (dropCapPosition == DropCapPosition.bottomRight &&
+            p == restParagraphs.length - 1) {
+          final dropCapLines = (capHeight / lineHeight).ceil();
+          final splitLine = max(0, lines.length - dropCapLines);
+
+          int charSplit = 0;
+          double yPos = 0;
+          for (int i = 0; i < lines.length; i++) {
+            if (i == splitLine) break;
+            yPos += lines[i].height;
+            final pos = tp.getPositionForOffset(
+                Offset(tp.width - 1, yPos - lines[i].descent));
+            charSplit = pos.offset;
+          }
+
+          final beforeText = para.substring(0, min(charSplit, para.length));
+          final afterText = para.substring(min(charSplit, para.length));
+
+          if (beforeText.isNotEmpty) {
+            children.add(RichText(
+              text: TextSpan(text: beforeText, style: textStyle),
+              textDirection: textDirection,
+              textAlign: textAlign,
+            ));
+          }
+
+          children.add(Row(
+            crossAxisAlignment: lines.length > 1
+                ? CrossAxisAlignment.end
+                : CrossAxisAlignment.start,
+            children: [
+              Expanded(
+                child: RichText(
+                  text: TextSpan(text: afterText, style: textStyle),
+                  textDirection: textDirection,
+                  textAlign: textAlign,
+                ),
+              ),
+              Padding(
+                padding: dropCapPadding,
+                child: dropCap ??
+                    RichText(
+                      textDirection: textDirection,
+                      textAlign: textAlign,
+                      text: TextSpan(text: dropCapStr, style: capStyle),
+                    ),
+              ),
+            ],
+          ));
+        } else {
+          children.add(RichText(
+            text: span,
+            textDirection: textDirection,
+            textAlign: textAlign,
+          ));
+        }
+
+        if (p < restParagraphs.length - 1) {
+          children.add(const SizedBox(height: 4));
+        }
+      }
+
+      return Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        mainAxisSize: MainAxisSize.min,
+        children: children,
+      );
+    });
+  }
+}
+
+// 以下 MarkdownParser 与 MarkdownSpan 与 Markup 类保持原样即可
+class MarkdownParser {
+  final String data;
+  late List<MarkdownSpan> spans;
+  String plainText = '';
+
+  List<TextSpan> toTextSpanList() => spans.map((s) => s.toTextSpan()).toList();
+
+  MarkdownParser subchars(int startIndex, [int? endIndex]) {
+    final subspans = <MarkdownSpan>[];
+    int skip = startIndex;
+    for (var span in spans) {
+      if (skip <= 0) {
+        subspans.add(span);
+      } else if (span.text.length < skip) {
+        skip -= span.text.length;
+      } else {
+        subspans.add(MarkdownSpan(
+          style: span.style,
+          markups: span.markups,
+          text: span.text.substring(skip),
+        ));
+        skip = 0;
+      }
+    }
+    return MarkdownParser(subspans.map((e) => e.text).join());
+  }
+
+  MarkdownParser(this.data) {
+    plainText = '';
+    spans = [MarkdownSpan(text: '', markups: [], style: const TextStyle())];
+
+    bool bold = false, italic = false, underline = false;
+    const b = '**', i = '_', u = '++';
+
+    void addSpan(String markup, bool isOpening) {
+      final markups = [Markup(markup, isOpening)];
+      if (bold && markup != b) markups.add(Markup(b, true));
+      if (italic && markup != i) markups.add(Markup(i, true));
+      if (underline && markup != u) markups.add(Markup(u, true));
+
+      spans.add(MarkdownSpan(
+        text: '',
+        markups: markups,
+        style: TextStyle(
+          fontWeight: bold ? FontWeight.bold : null,
+          fontStyle: italic ? FontStyle.italic : null,
+          decoration: underline ? TextDecoration.underline : null,
+        ),
+      ));
+    }
+
+    bool check(int i, String m) =>
+        data.substring(i, min(i + m.length, data.length)) == m;
+
+    for (int c = 0; c < data.length; c++) {
+      if (check(c, b)) {
+        bold = !bold;
+        addSpan(b, bold);
+        c += b.length - 1;
+      } else if (check(c, i)) {
+        italic = !italic;
+        addSpan(i, italic);
+        c += i.length - 1;
+      } else if (check(c, u)) {
+        underline = !underline;
+        addSpan(u, underline);
+        c += u.length - 1;
+      } else {
+        spans.last.text += data[c];
+        plainText += data[c];
+      }
+    }
+  }
+}
+
+class MarkdownSpan {
+  final TextStyle style;
+  final List<Markup> markups;
+  String text;
+
+  MarkdownSpan(
+      {required this.text, required this.style, required this.markups});
+
+  TextSpan toTextSpan() => TextSpan(text: text, style: style);
+}
+
+class Markup {
+  final String code;
+  final bool isActive;
+
+  Markup(this.code, this.isActive);
+}

+ 117 - 0
lib/widget/gradually_print_text.dart

@@ -0,0 +1,117 @@
+import 'dart:async';
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
+
+class GraduallyPrintText extends StatefulWidget {
+  const GraduallyPrintText({
+    super.key,
+    this.initTxt,
+    required this.graduallyController,
+    this.textStyle,
+  });
+
+  final TextStyle? textStyle;
+  final GraduallyController graduallyController;
+  final String? initTxt;
+
+  @override
+  State<GraduallyPrintText> createState() => _GraduallyPrintTextState();
+}
+
+typedef GraduallyTxtListener = void Function(String txt);
+
+class GraduallyController {
+  String graduallyTxt = '';
+  int progressIndex = 0;
+  Timer? appendTimer;
+  bool? isAppendDone;
+  bool? isShowDone;
+  Duration printSpeed;
+
+  GraduallyTxtListener? listener;
+  VoidCallback? finishedListener;
+
+  GraduallyController({this.printSpeed = const Duration(milliseconds: 60)});
+
+  setGraduallyTxtListener(GraduallyTxtListener listener) {
+    this.listener = listener;
+  }
+
+  setGraduallyFinishedListener(VoidCallback finishedListener) {
+    this.finishedListener = finishedListener;
+  }
+
+  append(String txt) {
+    graduallyTxt += txt;
+    _startAppend();
+  }
+
+  void _initAppend() {
+    progressIndex = 0;
+    isAppendDone = null;
+    isShowDone = null;
+    appendTimer = null;
+  }
+
+  appendDone() {
+    isAppendDone = true;
+    // debugPrint('GraduallyMdText: appendDone');
+  }
+
+  _startAppend() {
+    if (appendTimer != null) {
+      return;
+    }
+    _initAppend();
+    appendTimer = Timer.periodic(printSpeed, (timer) {
+      // debugPrint('GraduallyMdText-progressIndex: $progressIndex');
+      if (progressIndex < graduallyTxt.length) {
+        progressIndex += 1;
+        listener?.call(graduallyTxt.substring(0, progressIndex));
+      } else if (isAppendDone == true) {
+        // debugPrint('GraduallyMdText: appendTimer done');
+        dispose();
+        finishedListener?.call();
+      }
+    });
+  }
+
+  dispose() {
+    appendTimer?.cancel();
+    isAppendDone = null;
+    isShowDone = true;
+    appendTimer = null;
+  }
+}
+
+class _GraduallyPrintTextState extends State<GraduallyPrintText> {
+  String mdTxt = '';
+
+  @override
+  void initState() {
+    super.initState();
+    mdTxt = widget.initTxt ?? '';
+    widget.graduallyController.setGraduallyTxtListener((txt) {
+      if (mounted) {
+        setState(() {
+          mdTxt = txt;
+        });
+      }
+    });
+  }
+
+  @override
+  void dispose() {
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return SelectionArea(
+      child: Text(
+        mdTxt,
+        style: widget.textStyle,
+      ),
+    );
+  }
+}

+ 67 - 0
lib/widget/rich_text_replace.dart

@@ -0,0 +1,67 @@
+import 'package:flutter/material.dart';
+
+class RichTextReplace extends StatelessWidget {
+  final String text;
+  final Map<String, String>? items;
+  final TextStyle? defaultStyle;
+  final TextStyle? replacedStyle;
+
+  const RichTextReplace({
+    super.key,
+    required this.text,
+    this.items,
+    this.defaultStyle,
+    this.replacedStyle,
+  });
+
+  @override
+  Widget build(BuildContext context) {
+    final spans = _buildTextSpans(
+      text,
+      items ?? {}, // 如果为空,传空 map
+    );
+
+    return RichText(
+      text: TextSpan(
+        children: spans,
+        style: defaultStyle ?? DefaultTextStyle.of(context).style,
+      ),
+    );
+  }
+
+  List<InlineSpan> _buildTextSpans(
+      String text, Map<String, String> replaceMap) {
+    final List<InlineSpan> spans = [];
+    int currentIndex = 0;
+
+    final regex = RegExp(r'\$\{(.*?)\}');
+
+    for (final match in regex.allMatches(text)) {
+      final fullKey = match.group(0)!; // e.g. ${unlockCount}
+      final key = match.group(1)!; // e.g. unlockCount
+
+      // 普通文本部分
+      if (match.start > currentIndex) {
+        spans.add(TextSpan(text: text.substring(currentIndex, match.start)));
+      }
+
+      // 替换值(优先使用 ${key} 匹配,兼容性更好)
+      final replaced = replaceMap[fullKey] ?? replaceMap[key] ?? '';
+
+      // 加粗或自定义样式的替换值
+      spans.add(TextSpan(
+        text: replaced,
+        style: replacedStyle ?? const TextStyle(fontWeight: FontWeight.bold),
+      ));
+
+      currentIndex = match.end;
+    }
+
+    // 尾部普通文本
+    if (currentIndex < text.length) {
+      spans.add(TextSpan(text: text.substring(currentIndex)));
+    }
+
+    return spans;
+  }
+}