Просмотр исходного кода

Merge branch 'v1.1.0' of git.atmob.com:Atmob-Flutter/location2025 into v1.1.0

zhoukun 6 месяцев назад
Родитель
Сommit
2147b97b83
39 измененных файлов с 2224 добавлено и 90 удалено
  1. BIN
      assets/images/bg_dialog_track_error.webp
  2. BIN
      assets/images/bg_track_pie_chat.webp
  3. BIN
      assets/images/icon_call_phone.webp
  4. BIN
      assets/images/icon_track_daily_summary.webp
  5. BIN
      assets/images/icon_track_daily_summary_arrow.webp
  6. BIN
      assets/images/img_track_no_data.webp
  7. 20 0
      assets/string/base/string.xml
  8. 11 8
      lib/data/api/atmob_api.dart
  9. 38 0
      lib/data/api/atmob_api.g.dart
  10. 84 0
      lib/data/api/atmob_stream_api.c.dart
  11. 15 0
      lib/data/api/atmob_stream_api.dart
  12. 30 0
      lib/data/api/response/track_daily_summary_response.dart
  13. 30 0
      lib/data/api/response/track_daily_summary_response.g.dart
  14. 54 0
      lib/data/bean/stream_chat_origin_data.dart
  15. 45 0
      lib/data/bean/stream_chat_origin_data.g.dart
  16. 35 0
      lib/data/bean/track_summary.dart
  17. 49 5
      lib/data/repositories/track_repository.dart
  18. 24 15
      lib/di/get_it.config.dart
  19. 17 0
      lib/di/network_module.dart
  20. 115 0
      lib/dialog/track_error_tips_dialog.dart
  21. 51 0
      lib/module/track/track_day_detail/time_proportion/pie_chat_data.dart
  22. 0 3
      lib/module/track/track_day_detail/time_proportion/time_proportion_controller.dart
  23. 0 31
      lib/module/track/track_day_detail/time_proportion/time_proportion_view.dart
  24. 258 0
      lib/module/track/track_day_detail/time_proportion/track_time_pie_chat.dart
  25. 21 9
      lib/module/track/track_day_detail/track_daily_item.dart
  26. 227 1
      lib/module/track/track_day_detail/track_day_detail_controller.dart
  27. 301 15
      lib/module/track/track_day_detail/track_day_detail_view.dart
  28. 3 1
      lib/module/track/track_status.dart
  29. 31 1
      lib/resource/assets.gen.dart
  30. 52 0
      lib/resource/string.gen.dart
  31. 22 0
      lib/sdk/wechat/wechat_helper.dart
  32. 52 0
      lib/sdk/wechat/wechat_share_util.dart
  33. 1 1
      lib/socket/atmob_location_client.dart
  34. 24 0
      lib/utils/capture_util.dart
  35. 142 0
      lib/utils/sse_parse_util.dart
  36. 285 0
      lib/widget/drop_cap_text.dart
  37. 117 0
      lib/widget/gradually_print_text.dart
  38. 67 0
      lib/widget/rich_text_replace.dart
  39. 3 0
      pubspec.yaml

BIN
assets/images/bg_dialog_track_error.webp


BIN
assets/images/bg_track_pie_chat.webp


BIN
assets/images/icon_call_phone.webp


BIN
assets/images/icon_track_daily_summary.webp


BIN
assets/images/icon_track_daily_summary_arrow.webp


BIN
assets/images/img_track_no_data.webp


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

@@ -325,4 +325,24 @@
     <string name="track_detail_mobile">移动网络</string>
     <string name="track_detail_error">当前对方定位丢失</string>
     <string name="track_detail_time_proportion">地点占比时长</string>
+    <string name="track_detail_no_data">当前暂无其他更新信息</string>
+    <string name="track_detail_see_error">查看原因</string>
+    <string name="dialog_track_error_title">定位规则</string>
+    <string name="dialog_track_error_tip1">定位权限问题</string>
+    <string name="dialog_track_error_tip2">手机关机</string>
+    <string name="dialog_track_error_tip3">卸载app</string>
+    <string name="dialog_track_error_tip1_desc">请检查对方定位权限是否为始终允许</string>
+    <string name="dialog_track_error_tip2_desc">尝试给TA打电话,确认手机是否关机</string>
+    <string name="dialog_track_error_tip3_desc">
+        若对方卸载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';
@@ -156,8 +155,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(
@@ -181,11 +179,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")
@@ -206,8 +206,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(
@@ -217,6 +216,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(

+ 38 - 0
lib/data/api/atmob_api.g.dart

@@ -1625,6 +1625,44 @@ class _AtmobApi implements AtmobApi {
   }
 
   @override
+  Future<BaseResponse<TrackDailySummaryResponse>> trackDailySummary(
+    QueryTrackRequest request,
+    RequestOptions options,
+  ) async {
+    final _extra = <String, dynamic>{};
+    final queryParameters = <String, dynamic>{};
+    final _headers = <String, dynamic>{};
+    final _data = <String, dynamic>{};
+    _data.addAll(request.toJson());
+    final newOptions = newRequestOptions(options);
+    newOptions.extra.addAll(_extra);
+    newOptions.headers.addAll(_dio.options.headers);
+    newOptions.headers.addAll(_headers);
+    final _options = newOptions.copyWith(
+      method: 'POST',
+      baseUrl: _combineBaseUrls(
+        _dio.options.baseUrl,
+        baseUrl,
+      ),
+      queryParameters: queryParameters,
+      path: '/s/v1/location/track/daily/summary',
+    )..data = _data;
+    final _result = await _dio.fetch<Map<String, dynamic>>(_options);
+    late BaseResponse<TrackDailySummaryResponse> _value;
+    try {
+      _value = BaseResponse<TrackDailySummaryResponse>.fromJson(
+        _result.data!,
+        (json) =>
+            TrackDailySummaryResponse.fromJson(json as Map<String, dynamic>),
+      );
+    } on Object catch (e, s) {
+      errorLogger?.logError(e, s, _options);
+      rethrow;
+    }
+    return _value;
+  }
+
+  @override
   Future<BaseResponse<dynamic>> notificationReport(
       NotificationReportRequest request) async {
     final _extra = <String, dynamic>{};

+ 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,29 @@
-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>();
@@ -90,7 +93,7 @@ class TrackRepository {
   }
 
   ///每日轨迹弹框报告-中台
-  Future<TrackDailyDialogsResponse>locationTrackDailyDialogs() {
+  Future<TrackDailyDialogsResponse> locationTrackDailyDialogs() {
     return atmobApi
         .locationTrackDailyDialogs(AppBaseRequest())
         .then(HttpHandler.handle(true))
@@ -99,4 +102,45 @@ 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));
+  }
 }

+ 24 - 15
lib/di/get_it.config.dart

@@ -13,6 +13,7 @@ import 'package:get_it/get_it.dart' as _i174;
 import 'package:injectable/injectable.dart' as _i526;
 
 import '../data/api/atmob_api.dart' as _i243;
+import '../data/api/atmob_stream_api.dart' as _i329;
 import '../data/repositories/account_repository.dart' as _i20;
 import '../data/repositories/config_repository.dart' as _i825;
 import '../data/repositories/contact_repository.dart' as _i850;
@@ -56,27 +57,31 @@ extension GetItInjectableX on _i174.GetIt {
       environmentFilter,
     );
     final networkModule = _$NetworkModule();
-    gh.factory<_i973.SplashController>(() => _i973.SplashController());
-    gh.factory<_i756.TrackDetailController>(
-        () => _i756.TrackDetailController());
     gh.factory<_i256.AboutController>(() => _i256.AboutController());
-    gh.factory<_i769.FeedBackController>(() => _i769.FeedBackController());
     gh.factory<_i923.BrowserController>(() => _i923.BrowserController());
+    gh.factory<_i769.FeedBackController>(() => _i769.FeedBackController());
     gh.factory<_i108.PermissionSettingController>(
         () => _i108.PermissionSettingController());
+    gh.factory<_i973.SplashController>(() => _i973.SplashController());
+    gh.factory<_i756.TrackDetailController>(
+        () => _i756.TrackDetailController());
     gh.singleton<_i361.Dio>(() => networkModule.createDefaultDio());
     gh.lazySingleton<_i220.AtmobLocationClient>(
         () => _i220.AtmobLocationClient());
     gh.singleton<_i243.AtmobApi>(
         () => networkModule.provideAtmobApi(gh<_i361.Dio>()));
-    gh.lazySingleton<_i240.TrackRepository>(
-        () => _i240.TrackRepository(gh<_i243.AtmobApi>()));
+    gh.singleton<_i361.Dio>(
+      () => networkModule.createStreamDio(),
+      instanceName: 'stream',
+    );
+    gh.singleton<_i329.AtmobStreamApi>(() => networkModule
+        .provideAtmobStreamApi(gh<_i361.Dio>(instanceName: 'stream')));
     gh.lazySingleton<_i20.AccountRepository>(
         () => _i20.AccountRepository(gh<_i243.AtmobApi>()));
-    gh.lazySingleton<_i1053.FriendsRepository>(
-        () => _i1053.FriendsRepository(gh<_i243.AtmobApi>()));
     gh.lazySingleton<_i850.ContactRepository>(
         () => _i850.ContactRepository(gh<_i243.AtmobApi>()));
+    gh.lazySingleton<_i1053.FriendsRepository>(
+        () => _i1053.FriendsRepository(gh<_i243.AtmobApi>()));
     gh.lazySingleton<_i791.MessageRepository>(
         () => _i791.MessageRepository(gh<_i243.AtmobApi>()));
     gh.lazySingleton<_i983.UrgentContactRepository>(
@@ -93,16 +98,15 @@ extension GetItInjectableX on _i174.GetIt {
           gh<_i20.AccountRepository>(),
           gh<_i1053.FriendsRepository>(),
         ));
-    gh.factory<_i518.TrackController>(() => _i518.TrackController(
-          gh<_i240.TrackRepository>(),
-          gh<_i1053.FriendsRepository>(),
-          gh<_i20.AccountRepository>(),
-        ));
     gh.factory<_i433.NewsPendingListController>(
         () => _i433.NewsPendingListController(
               gh<_i791.MessageRepository>(),
               gh<_i1053.FriendsRepository>(),
             ));
+    gh.lazySingleton<_i240.TrackRepository>(() => _i240.TrackRepository(
+          gh<_i243.AtmobApi>(),
+          gh<_i329.AtmobStreamApi>(),
+        ));
     gh.factory<_i955.AddUrgentContactController>(
         () => _i955.AddUrgentContactController(
               gh<_i983.UrgentContactRepository>(),
@@ -113,10 +117,10 @@ extension GetItInjectableX on _i174.GetIt {
               gh<_i983.UrgentContactRepository>(),
               gh<_i20.AccountRepository>(),
             ));
-    gh.factory<_i492.FriendSettingController>(
-        () => _i492.FriendSettingController(gh<_i1053.FriendsRepository>()));
     gh.factory<_i897.AddFriendDialogController>(
         () => _i897.AddFriendDialogController(gh<_i1053.FriendsRepository>()));
+    gh.factory<_i492.FriendSettingController>(
+        () => _i492.FriendSettingController(gh<_i1053.FriendsRepository>()));
     gh.lazySingleton<_i814.MemberRepository>(() => _i814.MemberRepository(
           gh<_i243.AtmobApi>(),
           gh<_i20.AccountRepository>(),
@@ -137,6 +141,11 @@ extension GetItInjectableX on _i174.GetIt {
         ));
     gh.lazySingleton<_i779.PaymentStatusManager>(
         () => _i779.PaymentStatusManager(gh<_i814.MemberRepository>()));
+    gh.factory<_i518.TrackController>(() => _i518.TrackController(
+          gh<_i240.TrackRepository>(),
+          gh<_i1053.FriendsRepository>(),
+          gh<_i20.AccountRepository>(),
+        ));
     gh.factory<_i269.MemberController>(() => _i269.MemberController(
           gh<_i20.AccountRepository>(),
           gh<_i814.MemberRepository>(),

+ 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);
+  }
 }

+ 115 - 0
lib/dialog/track_error_tips_dialog.dart

@@ -0,0 +1,115 @@
+import 'package:flutter/cupertino.dart';
+import 'package:flutter_screenutil/flutter_screenutil.dart';
+import 'package:flutter_smart_dialog/flutter_smart_dialog.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/common_expand.dart';
+
+import '../utils/common_style.dart';
+
+class TrackErrorTipsDialog {
+  static const String _tag = 'TrackErrorTipsDialog';
+
+  static void show() {
+    SmartDialog.show(
+      tag: _tag,
+      maskColor: ColorName.black80,
+      builder: (_) => _TrackErrorTipsView(),
+    );
+  }
+
+  static void dismiss() {
+    SmartDialog.dismiss(tag: _tag);
+  }
+}
+
+class _TrackErrorTipsView extends StatelessWidget {
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      width: 274.w,
+      decoration: BoxDecoration(
+          borderRadius: BorderRadius.circular(16.r),
+          border: Border.all(
+            color: ColorName.white,
+            width: 3.w,
+          ),
+          gradient: LinearGradient(colors: [
+            '#E4E4FF'.color,
+            ColorName.white,
+          ], stops: const [
+            0.0,
+            0.2
+          ], begin: Alignment.topCenter, end: Alignment.bottomCenter)),
+      child: Stack(
+        children: [
+          Assets.images.bgDialogTrackError.image(width: double.infinity),
+          Container(
+            width: double.infinity,
+            padding: EdgeInsets.symmetric(horizontal: 17.w),
+            child: IntrinsicHeight(
+              child: Column(
+                crossAxisAlignment: CrossAxisAlignment.start,
+                children: [
+                  SizedBox(height: 24.w),
+                  Align(
+                    alignment: Alignment.center,
+                    child: Text(
+                      StringName.dialogTrackErrorTitle,
+                      style: TextStyle(
+                          fontSize: 17.sp,
+                          color: '#333333'.color,
+                          fontWeight: FontWeight.bold),
+                    ),
+                  ),
+                  SizedBox(height: 35.w),
+                  buildTip(StringName.dialogTrackErrorTip1),
+                  buildTipDesc(StringName.dialogTrackErrorTip1Desc),
+                  buildTip(StringName.dialogTrackErrorTip2),
+                  buildTipDesc(StringName.dialogTrackErrorTip2Desc),
+                  buildTip(StringName.dialogTrackErrorTip3),
+                  buildTipDesc(StringName.dialogTrackErrorTip3Desc),
+                  SizedBox(height: 7.w),
+                  GestureDetector(
+                    onTap: () {
+                      TrackErrorTipsDialog.dismiss();
+                    },
+                    child: Container(
+                        decoration: getPrimaryBtnDecoration(100.r),
+                        width: double.infinity,
+                        height: 40.w,
+                        child: Center(
+                            child: Text(StringName.dialogTrackErrorBtnTxt,
+                                style: TextStyle(
+                                    fontSize: 14.sp, color: ColorName.white)))),
+                  ),
+                  SizedBox(height: 20.w),
+                ],
+              ),
+            ),
+          )
+        ],
+      ),
+    );
+  }
+
+  Widget buildTip(String title) {
+    return Container(
+      margin: EdgeInsets.only(bottom: 6.w),
+      child: Text(title,
+          style: TextStyle(
+              fontSize: 13.sp,
+              color: '#404040'.color,
+              fontWeight: FontWeight.bold)),
+    );
+  }
+
+  Widget buildTipDesc(String desc) {
+    return Container(
+      margin: EdgeInsets.only(bottom: 24.w),
+      child:
+          Text(desc, style: TextStyle(fontSize: 12.sp, color: '#666666'.color)),
+    );
+  }
+}

+ 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),
+];

+ 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;
+}

+ 21 - 9
lib/module/track/track_day_detail/track_daily_item.dart

@@ -4,6 +4,7 @@ import 'package:flutter/cupertino.dart';
 import 'package:flutter_screenutil/flutter_screenutil.dart';
 import 'package:location/data/bean/track_daily_bean.dart';
 import 'package:location/data/consts/constants.dart';
+import 'package:location/dialog/track_error_tips_dialog.dart';
 import 'package:location/module/track/track_util.dart';
 import 'package:location/resource/assets.gen.dart';
 import 'package:location/resource/colors.gen.dart';
@@ -24,14 +25,14 @@ Widget buildTrackDailyItem(TrackDailyBean bean, bool isEnd) {
           if (bean.status == TrackStatus.moving) {
             return _buildMovingTrackDailyItem(bean);
           } else if (bean.status == TrackStatus.stay) {
-            return _buildStayTrackDailyItem(bean);
-          } else if (bean.status == TrackStatus.abnormal) {
-            return _buildAbnormalTrackDailyItem(bean);
+            return buildStayTrackDailyItem(bean);
+          } else if (bean.status == TrackStatus.error) {
+            return buildErrorTrackDailyItem(bean);
           } else {
             return SizedBox(height: 50.w, child: Text('未知轨迹,请更新最新应用版本'));
           }
         }),
-        if (isEnd) _buildEndPoint(bean)
+        if (isEnd) buildEndPoint(bean)
       ],
     ),
   );
@@ -95,7 +96,8 @@ Widget _buildMovingTrackDailyItem(TrackDailyBean bean) {
   );
 }
 
-Widget _buildStayTrackDailyItem(TrackDailyBean bean) {
+Widget buildStayTrackDailyItem(TrackDailyBean bean,
+    {EdgeInsetsGeometry? contentPadding}) {
   return Column(
     crossAxisAlignment: CrossAxisAlignment.start,
     children: [
@@ -123,7 +125,7 @@ Widget _buildStayTrackDailyItem(TrackDailyBean bean) {
             Expanded(
                 child: Container(
               padding: EdgeInsets.all(10.w),
-              margin: EdgeInsets.only(top: 20.w),
+              margin: contentPadding ?? EdgeInsets.only(top: 20.w),
               decoration: BoxDecoration(
                   borderRadius: BorderRadius.circular(8.r),
                   gradient: LinearGradient(colors: [
@@ -166,7 +168,8 @@ Widget _buildStayTrackDailyItem(TrackDailyBean bean) {
   );
 }
 
-Widget _buildAbnormalTrackDailyItem(TrackDailyBean bean) {
+Widget buildErrorTrackDailyItem(TrackDailyBean bean,
+    {EdgeInsetsGeometry? contentPadding}) {
   return Column(
     crossAxisAlignment: CrossAxisAlignment.start,
     children: [
@@ -195,7 +198,7 @@ Widget _buildAbnormalTrackDailyItem(TrackDailyBean bean) {
                 child: Container(
               height: 50.w,
               padding: EdgeInsets.all(10.w),
-              margin: EdgeInsets.only(top: 41.w, bottom: 7.w),
+              margin: contentPadding ?? EdgeInsets.only(top: 41.w, bottom: 7.w),
               decoration: BoxDecoration(
                   borderRadius: BorderRadius.circular(8.r),
                   gradient: LinearGradient(colors: [
@@ -212,6 +215,15 @@ Widget _buildAbnormalTrackDailyItem(TrackDailyBean bean) {
                           color: '#333333'.color,
                           fontWeight: FontWeight.bold)),
                   SizedBox(width: 10.w),
+                  GestureDetector(
+                    onTap: () {
+                      TrackErrorTipsDialog.show();
+                    },
+                    child: Text(
+                      StringName.trackDetailSeeError,
+                      style: TextStyle(fontSize: 11.sp, color: '#4476FF'.color),
+                    ),
+                  ),
                   Spacer(),
                   Assets.images.imgTrackAiAnalyse.image(width: 73.w),
                   SizedBox(width: 6.w),
@@ -248,7 +260,7 @@ Widget _buildTimeText(int time) {
   );
 }
 
-Widget _buildEndPoint(TrackDailyBean bean) {
+Widget buildEndPoint(TrackDailyBean bean) {
   return Column(
     children: [
       SizedBox(height: 4.w),

+ 227 - 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';
@@ -6,9 +10,21 @@ 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/sdk/wechat/wechat_share_util.dart';
+import 'package:location/utils/async_util.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/capture_util.dart';
+import '../../../utils/http_handler.dart';
+import '../../../widget/gradually_print_text.dart';
+import '../track_status.dart';
 
 class TrackDayDetailController extends BaseController {
   final TrackDays days;
@@ -19,6 +35,9 @@ class TrackDayDetailController extends BaseController {
 
   final RxBool _isExpanded = RxBool(false);
   final RxBool _isRequested = RxBool(false);
+  final RxBool _isHideExpand = RxBool(false);
+
+  bool get isHideExpand => _isHideExpand.value;
 
   bool get isRequested => _isRequested.value;
 
@@ -26,8 +45,35 @@ class TrackDayDetailController extends BaseController {
 
   bool get trackNoData => _trackNoData.value;
 
+  final Rxn<Pair<TrackExpandType, TrackDailyBean>> _expandSituation = Rxn();
+
+  Pair<TrackExpandType, TrackDailyBean>? get expandSituation =>
+      _expandSituation.value;
+
+  final RxList<PieChatData> pieChatData = RxList<PieChatData>();
+  int indexPieChatColor = 0;
+
   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;
+
+  final GlobalKey shareGlobalKey = GlobalKey();
+
+  CancelableFuture? summaryFuture;
+
   TrackDayDetailController(this.days, bool isExpand) {
     trackRepository = TrackRepository.getInstance();
     _isExpanded.value = isExpand;
@@ -37,6 +83,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() {
@@ -52,6 +125,12 @@ class TrackDayDetailController extends BaseController {
       _isRequested.value = true;
       _trackNoData.value = (list == null || list.isEmpty == true);
       trackDailyList.assignAll(list ?? []);
+      if (list != null && list.length == 1) {
+        _isExpanded.value = true;
+        _isHideExpand.value = true;
+      }
+      _dealPieChatData();
+      _dealTrackExpandData();
     }).catchError((error) {
       CustomLoadingDialog.hide();
       _isRequested.value = false;
@@ -59,7 +138,154 @@ 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) {
+      TrackExpandType? expandType;
+      TrackDailyBean? expandBean;
+      for (var i = 0; i < list.length; i++) {
+        final bean = list[i];
+        if (bean.status == TrackStatus.error) {
+          expandType = TrackExpandType.error;
+          expandBean = bean;
+          break;
+        } else if (bean.status == TrackStatus.stay) {
+          if (expandBean == null) {
+            expandType = TrackExpandType.stay;
+            expandBean = bean;
+          }
+          continue;
+        }
+      }
+      if (expandType != null && expandBean != null) {
+        if (expandType == TrackExpandType.error &&
+            list.indexOf(expandBean) == list.length - 1) {
+          expandType = TrackExpandType.errorNow;
+        } else if (expandType == TrackExpandType.stay &&
+            list.indexOf(expandBean) == list.length - 1) {
+          expandType = TrackExpandType.stayNow;
+        }
+        _expandSituation.value = Pair(expandType, expandBean);
+      }
+    }
+  }
+
   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);
+    }
+  }
+
+  void onShareClick() async {
+    WechatShareUtil.shareWidgetToWeChat(shareGlobalKey);
+  }
+
+  @override
+  void onClose() {
+    super.onClose();
+    _streamChatSubscription?.cancel();
+    graduallyController.dispose();
+  }
 }

+ 301 - 15
lib/module/track/track_day_detail/track_day_detail_view.dart

@@ -6,18 +6,27 @@ 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';
 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);
   }
@@ -58,24 +67,258 @@ class TrackDayDetailView extends BaseView<TrackDayDetailController> {
             )),
             SliverToBoxAdapter(
               child: buildProportionDurationView(),
-            )
+            ),
+            SliverToBoxAdapter(
+              child: buildTrackDailySummaryView(),
+            ),
+            SliverToBoxAdapter(
+                child: Container(
+              height: 80.w,
+              color: '#F8F5FF'.color,
+            )),
           ],
         ),
-        buildFoldView(),
+        _buildFoldBtn(),
       ],
     );
   }
 
-  Widget _buildTrackNoData() {
+  Widget buildTrackDailySummaryView() {
+    return RepaintBoundary(
+      key: controller.shareGlobalKey,
+      child: 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: 10.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(
-      child: Text('无数据'),
+      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(),
+        GestureDetector(
+          onTap: controller.onShareClick,
+          child: 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 buildFoldView() {
+  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(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Builder(builder: (context) {
+            if (controller.expandSituation != null) {
+              if (controller.expandSituation!.first == TrackExpandType.error ||
+                  controller.expandSituation!.first ==
+                      TrackExpandType.errorNow) {
+                return buildErrorTrackDailyItem(
+                    controller.expandSituation!.second,
+                    contentPadding: EdgeInsets.only(top: 46.w, bottom: 12.w));
+              } else {
+                return buildStayTrackDailyItem(
+                    controller.expandSituation!.second,
+                    contentPadding: EdgeInsets.only(top: 35.w));
+              }
+            } else {
+              return SizedBox.shrink();
+            }
+          }),
+          if (controller.expandSituation != null &&
+              (controller.expandSituation?.first == TrackExpandType.error ||
+                  controller.expandSituation?.first == TrackExpandType.stay))
+            buildEndPoint(controller.expandSituation!.second)
+        ],
+      );
+    });
+  }
+
+  Widget _buildTrackNoData() {
+    return Column(
+      children: [
+        SizedBox(height: 0.048.sh),
+        Assets.images.imgTrackNoData.image(width: 78.5.w),
+        SizedBox(height: 7.w),
+        Text(StringName.trackDetailNoData,
+            style: TextStyle(fontSize: 11.sp, color: ColorName.black60))
+      ],
+    );
+  }
+
+  Widget _buildFoldBtn() {
     return Obx(() {
       return Visibility(
-        visible: !controller.trackNoData,
+        visible: !controller.trackNoData && !controller.isHideExpand,
         child: Positioned(
           top: 2.w,
           right: 5.w,
@@ -113,17 +356,60 @@ 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() {
     return Obx(() {
-      return SliverPadding(
-        padding: EdgeInsets.only(top: 20.w, bottom: 12.w),
-        sliver: SliverList.builder(
-            itemBuilder: buildHistoryTrackItem,
-            itemCount: controller.trackDailyList.length),
-      );
+      if (controller.isExpanded) {
+        return SliverPadding(
+          padding: EdgeInsets.only(top: 20.w, bottom: 12.w),
+          sliver: SliverList.builder(
+              itemBuilder: buildHistoryTrackItem,
+              itemCount: controller.trackDailyList.length),
+        );
+      } else {
+        return SliverPadding(
+            padding: EdgeInsets.only(
+                top: 20.w, bottom: 12.w, left: 12.w, right: 12.w),
+            sliver: SliverToBoxAdapter(
+              child: _buildFoldContentView(),
+            ));
+      }
     });
   }
 

+ 3 - 1
lib/module/track/track_status.dart

@@ -2,5 +2,7 @@
 abstract class TrackStatus {
   static const int moving = 0;
   static const int stay = 1;
-  static const int abnormal = 2;
+  static const int error = 2;
 }
+
+enum TrackExpandType { error, stay, stayNow, errorNow }

+ 31 - 1
lib/resource/assets.gen.dart

@@ -24,6 +24,10 @@ class $AssetsImagesGen {
   AssetGenImage get bgDialogLocationPermissionIos => const AssetGenImage(
       'assets/images/bg_dialog_location_permission_ios.webp');
 
+  /// File path: assets/images/bg_dialog_track_error.webp
+  AssetGenImage get bgDialogTrackError =>
+      const AssetGenImage('assets/images/bg_dialog_track_error.webp');
+
   /// File path: assets/images/bg_friend_item.webp
   AssetGenImage get bgFriendItem =>
       const AssetGenImage('assets/images/bg_friend_item.webp');
@@ -48,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');
@@ -100,6 +108,10 @@ class $AssetsImagesGen {
   AssetGenImage get iconBlackBack =>
       const AssetGenImage('assets/images/icon_black_back.webp');
 
+  /// File path: assets/images/icon_call_phone.webp
+  AssetGenImage get iconCallPhone =>
+      const AssetGenImage('assets/images/icon_call_phone.webp');
+
   /// File path: assets/images/icon_cb_selected.webp
   AssetGenImage get iconCbSelected =>
       const AssetGenImage('assets/images/icon_cb_selected.webp');
@@ -416,6 +428,14 @@ class $AssetsImagesGen {
   AssetGenImage get iconTrackDailyReport =>
       const AssetGenImage('assets/images/icon_track_daily_report.webp');
 
+  /// File path: assets/images/icon_track_daily_summary.webp
+  AssetGenImage get iconTrackDailySummary =>
+      const AssetGenImage('assets/images/icon_track_daily_summary.webp');
+
+  /// File path: assets/images/icon_track_daily_summary_arrow.webp
+  AssetGenImage get iconTrackDailySummaryArrow =>
+      const AssetGenImage('assets/images/icon_track_daily_summary_arrow.webp');
+
   /// File path: assets/images/icon_track_detail_time_base_arrow.webp
   AssetGenImage get iconTrackDetailTimeBaseArrow => const AssetGenImage(
       'assets/images/icon_track_detail_time_base_arrow.webp');
@@ -528,17 +548,23 @@ class $AssetsImagesGen {
   AssetGenImage get imgTrackExample =>
       const AssetGenImage('assets/images/img_track_example.webp');
 
+  /// File path: assets/images/img_track_no_data.webp
+  AssetGenImage get imgTrackNoData =>
+      const AssetGenImage('assets/images/img_track_no_data.webp');
+
   /// List of all assets
   List<AssetGenImage> get values => [
         bgAddFriendDialog,
         bgCheckLocationPermission,
         bgDialogLocationPermissionIos,
+        bgDialogTrackError,
         bgFriendItem,
         bgLoginHeadContainer,
         bgMemberHeader,
         bgMineMemberCard,
         bgPageBackground,
         bgTrackLocationTie,
+        bgTrackPieChat,
         bgUrgentContactAdd,
         bgUrgentContactEmpty,
         bgUrgentContactLogo,
@@ -552,6 +578,7 @@ class $AssetsImagesGen {
         iconAvatarClose,
         iconAvatarSelected,
         iconBlackBack,
+        iconCallPhone,
         iconCbSelected,
         iconCbUnSelect,
         iconCheckboxSelected,
@@ -631,6 +658,8 @@ class $AssetsImagesGen {
         iconNewsItem,
         iconSplashTitle,
         iconTrackDailyReport,
+        iconTrackDailySummary,
+        iconTrackDailySummaryArrow,
         iconTrackDetailTimeBaseArrow,
         iconTrackError,
         iconTrackLocation,
@@ -658,7 +687,8 @@ class $AssetsImagesGen {
         imgMemberRetainContainer,
         imgMemberUserCancelsContainer,
         imgTrackAiAnalyse,
-        imgTrackExample
+        imgTrackExample,
+        imgTrackNoData
       ];
 }
 

+ 52 - 0
lib/resource/string.gen.dart

@@ -271,6 +271,40 @@ class StringName {
   static String get trackDetailMobile => 'track_detail_mobile'.tr; // 移动网络
   static String get trackDetailError => 'track_detail_error'.tr; // 当前对方定位丢失
   static String get trackDetailTimeProportion => 'track_detail_time_proportion'.tr; // 地点占比时长
+  static String get trackDetailNoData =>
+      'track_detail_no_data'.tr; // 当前暂无其他更新信息
+  static String get trackDetailSeeError => 'track_detail_see_error'.tr; // 查看原因
+  static String get dialogTrackErrorTitle =>
+      'dialog_track_error_title'.tr; // 定位规则
+  static String get dialogTrackErrorTip1 =>
+      'dialog_track_error_tip1'.tr; // 定位权限问题
+  static String get dialogTrackErrorTip2 =>
+      'dialog_track_error_tip2'.tr; // 手机关机
+  static String get dialogTrackErrorTip3 =>
+      'dialog_track_error_tip3'.tr; // 卸载app
+  static String get dialogTrackErrorTip1Desc =>
+      'dialog_track_error_tip1_desc'.tr; // 请检查对方定位权限是否为始终允许
+  static String get dialogTrackErrorTip2Desc =>
+      'dialog_track_error_tip2_desc'.tr; // 尝试给TA打电话,确认手机是否关机
+  static String get dialogTrackErrorTip3Desc =>
+      'dialog_track_error_tip3_desc'.tr; // 若对方卸载app,轨迹行程待TA重新下载恢复定位后,将会重新展示TA行程
+  static String get dialogTrackErrorBtnTxt =>
+      'dialog_track_error_btn_txt'.tr; // 我知道了
+  static String get trackDetailDailySummary =>
+      'track_detail_daily_summary'.tr; // 行为总结
+  static String get trackDetailDailySummaryShare =>
+      'track_detail_daily_summary_share'.tr; // 分享报告
+  static String get trackDetailDailySummaryGetFail =>
+      'track_detail_daily_summary_get_fail'.tr; // 行为总结获取失败
+  static String get trackDailySummaryPhone =>
+      'track_daily_summary_phone'.tr; // 手机情况
+  static String get trackDailySummaryStay =>
+      'track_daily_summary_stay'.tr; // 停留最长
+  static String get trackDailySummarytrack =>
+      'track_daily_summary_track'.tr; // 轨迹情况
+  static String get trackDailyCallPhone => 'track_daily_call_phone'.tr; // 联系TA
+  static String get trackDailySkipCallPhoneFail =>
+      'track_daily_skip_call_phone_fail'.tr; // 跳转拨号界面失败
 }
 class StringMultiSource {
   StringMultiSource._();
@@ -545,6 +579,24 @@ class StringMultiSource {
       'track_detail_mobile': '移动网络',
       'track_detail_error': '当前对方定位丢失',
       'track_detail_time_proportion': '地点占比时长',
+      'track_detail_no_data': '当前暂无其他更新信息',
+      'track_detail_see_error': '查看原因',
+      'dialog_track_error_title': '定位规则',
+      'dialog_track_error_tip1': '定位权限问题',
+      'dialog_track_error_tip2': '手机关机',
+      'dialog_track_error_tip3': '卸载app',
+      'dialog_track_error_tip1_desc': '请检查对方定位权限是否为始终允许',
+      'dialog_track_error_tip2_desc': '尝试给TA打电话,确认手机是否关机',
+      'dialog_track_error_tip3_desc': '若对方卸载app,轨迹行程待TA重新下载恢复定位后,将会重新展示TA行程',
+      'dialog_track_error_btn_txt': '我知道了',
+      'track_detail_daily_summary': '行为总结',
+      'track_detail_daily_summary_share': '分享报告',
+      'track_detail_daily_summary_get_fail': '行为总结获取失败',
+      'track_daily_summary_phone': '手机情况',
+      'track_daily_summary_stay': '停留最长',
+      'track_daily_summary_track': '轨迹情况',
+      'track_daily_call_phone': '联系TA',
+      'track_daily_skip_call_phone_fail': '跳转拨号界面失败',
     },
   };
 }

+ 22 - 0
lib/sdk/wechat/wechat_helper.dart

@@ -20,6 +20,28 @@ class WechatHelper {
     });
   }
 
+  static Future<void> shareImageToWechat({
+    required int scene,
+    Uint8List? imageData,
+    Uri? imageUri,
+  }) async {
+    return WechatKitPlatform.instance.shareImage(
+      scene: scene,
+      imageData: imageData,
+      imageUri: imageUri,
+    );
+  }
+
+  static Future<void> shareTextToWechat({
+    required int scene,
+    required String text,
+  }) async {
+    return WechatKitPlatform.instance.shareText(
+      scene: scene,
+      text: text,
+    );
+  }
+
   static Future<void> shareUrlToWechat({
     required int scene,
     required String webpageUrl,

+ 52 - 0
lib/sdk/wechat/wechat_share_util.dart

@@ -1,3 +1,4 @@
+import 'package:flutter/cupertino.dart';
 import 'package:flutter/foundation.dart';
 import 'package:flutter/services.dart';
 import 'package:location/sdk/wechat/wechat_helper.dart';
@@ -5,6 +6,8 @@ import 'package:wechat_kit/wechat_kit.dart';
 import '../../data/consts/web_url.dart';
 import '../../resource/assets.gen.dart';
 import '../../resource/string.gen.dart';
+import '../../utils/capture_util.dart';
+import 'package:image/image.dart' as img;
 
 class WechatShareUtil {
   WechatShareUtil._();
@@ -25,4 +28,53 @@ class WechatShareUtil {
     final ByteData byteData = await rootBundle.load(assetPath);
     return byteData.buffer.asUint8List();
   }
+
+  static Future<void> shareWidgetToWeChat(
+    GlobalKey repaintKey, {
+    int scene = WechatScene.kSession,
+    double pixelRatio = 2.5,
+    int maxBytes = 1024 * 1024,
+  }) async {
+    try {
+      final isInstall = await WechatHelper.isInstalled();
+      if (!isInstall) {
+        throw Exception(StringName.wechatNoInstall);
+      }
+
+      final captured = await CaptureUtil.captureWidgetToImage(repaintKey);
+      if (captured == null) {
+        throw Exception('截图失败');
+      }
+
+      final compressed =
+          await _compressImageToLimit(captured, maxBytes: maxBytes);
+      if (compressed == null) {
+        debugPrint("图片压缩失败");
+        return;
+      }
+
+      await WechatHelper.shareImageToWechat(
+        scene: scene,
+        imageData: compressed,
+      );
+    } catch (e) {
+      throw Exception('分享失败');
+    }
+  }
+
+  static Future<Uint8List?> _compressImageToLimit(Uint8List data,
+      {required int maxBytes}) async {
+    final decoded = img.decodeImage(data);
+    if (decoded == null) return null;
+
+    int quality = 95;
+    late Uint8List result;
+
+    do {
+      result = Uint8List.fromList(img.encodeJpg(decoded, quality: quality));
+      quality -= 5;
+    } while (result.lengthInBytes > maxBytes && quality > 10);
+
+    return result.lengthInBytes <= maxBytes ? result : null;
+  }
 }

+ 1 - 1
lib/socket/atmob_location_client.dart

@@ -209,7 +209,7 @@ class AtmobLocationClient {
     if (_webSocket == null) {
       return;
     }
-    // _webSocket!.sink.add(msg);
+    _webSocket!.sink.add(msg);
     AtmobLog.d(tag, 'send location: $msg');
   }
 

+ 24 - 0
lib/utils/capture_util.dart

@@ -0,0 +1,24 @@
+import 'dart:typed_data';
+import 'dart:ui' as ui;
+
+import 'package:flutter/cupertino.dart';
+import 'package:flutter/rendering.dart';
+
+class CaptureUtil {
+  CaptureUtil._();
+
+  static Future<Uint8List?> captureWidgetToImage(GlobalKey key,
+      {double pixelRatio = 2.5}) async {
+    try {
+      RenderRepaintBoundary boundary =
+          key.currentContext!.findRenderObject() as RenderRepaintBoundary;
+
+      final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio);
+      final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
+      return byteData?.buffer.asUint8List();
+    } catch (e) {
+      debugPrint("截图失败: $e");
+      return null;
+    }
+  }
+}

+ 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;
+  }
+}

+ 3 - 0
pubspec.yaml

@@ -139,6 +139,9 @@ dependencies:
   #跳转评价
   in_app_review: ^2.0.10
 
+  #图片压缩
+  image: ^4.5.4
+
   ######################地图########################
   flutter_map:
     path: plugins/map