Browse Source

[new]完善ai分关键词流程控制

zk 4 months ago
parent
commit
87337efd6b
29 changed files with 1127 additions and 2858 deletions
  1. 0 2824
      assets/anim/location_label.json
  2. BIN
      assets/anim/location_label.zip
  3. BIN
      assets/images/icon_track_ai_interpretation.webp
  4. BIN
      assets/images/icon_track_analyse_refresh.webp
  5. 14 0
      assets/string/base/string.xml
  6. 10 0
      lib/data/api/atmob_api.dart
  7. 78 0
      lib/data/api/atmob_api.g.dart
  8. 31 0
      lib/data/api/atmob_stream_api.c.dart
  9. 4 0
      lib/data/api/atmob_stream_api.dart
  10. 14 0
      lib/data/api/response/daily_keyword_response.dart
  11. 19 0
      lib/data/api/response/daily_keyword_response.g.dart
  12. 16 0
      lib/data/api/response/track_daily_interpret_response.dart
  13. 21 0
      lib/data/api/response/track_daily_interpret_response.g.dart
  14. 50 0
      lib/data/repositories/track_repository.dart
  15. 3 0
      lib/di/get_it.config.dart
  16. 245 6
      lib/module/analyse/location_analyse_controller.dart
  17. 298 15
      lib/module/analyse/location_analyse_page.dart
  18. 37 0
      lib/module/analyse/location_analyse_util.dart
  19. 9 4
      lib/module/track/track_day_detail/track_daily_item.dart
  20. 9 0
      lib/module/track/track_day_detail/track_day_detail_controller.dart
  21. 4 2
      lib/module/track/track_day_detail/track_day_detail_view.dart
  22. 12 2
      lib/resource/assets.gen.dart
  23. 24 4
      lib/resource/string.gen.dart
  24. 2 0
      lib/router/app_pages.dart
  25. 65 0
      lib/widget/animated_visibility.dart
  26. 10 0
      lib/widget/gradually_print_text.dart
  27. 69 0
      lib/widget/typewriter_text.dart
  28. 4 1
      plugins/map/lib/flutter_map.dart
  29. 79 0
      plugins/map/lib/src/utils/map_util.dart

File diff suppressed because it is too large
+ 0 - 2824
assets/anim/location_label.json


BIN
assets/anim/location_label.zip


BIN
assets/images/icon_track_ai_interpretation.webp


BIN
assets/images/icon_track_analyse_refresh.webp


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

@@ -352,4 +352,18 @@
     <string name="permission_phone_screen_setting_subtitle">
         获取权限,保证您的信息定位更精准,预测更加准确。
     </string>
+    <string name="track_analysing">无新定位,loca分析中...</string>
+    <string name="track_error_addr_analysing">异常地点,loca分析中...</string>
+    <string name="track_analysing_tips">正在结合对方过往的常去动态定位分析中,请稍等...</string>
+    <string name="track_analyse_refresh">刷新</string>
+    <string name="track_analyse_no_new_position">当前无对方新定位</string>
+    <string name="track_analyse_ai_interpretation">AI分析解读:</string>
+    <string name="track_analyse_ai_interpretation_details">
+        好的,现在开始根据用户目前无新定位信息分析用户目前可能在的地方或者是因为其他原因导致无新定位,我们会根据用户之前长逗留的地方分享用户可能在:
+    </string>
+    <string name="track_analyse_ai_interpretation_details_finally">根据用户过往定位分析得出。
+    </string>
+    <string name="track_analyse_result_title">分析结果总结:</string>
+    <string name="track_analyse_done">分析完毕</string>
+    <string name="track_analyse_done_detail">过往的常去动态定位分析完毕,请看下方</string>
 </resources>

+ 10 - 0
lib/data/api/atmob_api.dart

@@ -26,6 +26,7 @@ import 'package:location/data/api/request/user_avatar_update_request.dart';
 import 'package:location/data/api/response/configs_response.dart';
 import 'package:location/data/api/response/contact_list_response.dart';
 import 'package:location/data/api/response/contact_may_day_all_response.dart';
+import 'package:location/data/api/response/daily_keyword_response.dart';
 import 'package:location/data/api/response/friends_list_response.dart';
 import 'package:location/data/api/response/item_list_response.dart';
 import 'package:location/data/api/response/location_track_days_response.dart';
@@ -41,6 +42,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_action_response.dart';
 import 'package:location/data/api/response/track_daily_dialogs_response.dart';
+import 'package:location/data/api/response/track_daily_interpret_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';
@@ -245,4 +247,12 @@ abstract class AtmobApi {
 
   @POST("/s/v1/user/electric/report")
   Future<BaseResponse> electricReport(@Body() ElectricRequest request);
+
+  @POST("/s/v1/chat/daily/keyword")
+  Future<BaseResponse<DailyKeywordResponse>> dailyKeyword(
+      @Body() QueryTrackRequest request);
+
+  @POST("/s/v1/location/track/daily/interpret")
+  Future<BaseResponse<TrackDailyInterpretResponse>> trackDailyInterpret(
+      @Body() QueryTrackRequest request);
 }

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

@@ -1853,6 +1853,84 @@ class _AtmobApi implements AtmobApi {
     return _value;
   }
 
+  @override
+  Future<BaseResponse<DailyKeywordResponse>> dailyKeyword(
+      QueryTrackRequest request) async {
+    final _extra = <String, dynamic>{};
+    final queryParameters = <String, dynamic>{};
+    final _headers = <String, dynamic>{};
+    final _data = <String, dynamic>{};
+    _data.addAll(request.toJson());
+    final _options = _setStreamType<BaseResponse<DailyKeywordResponse>>(Options(
+      method: 'POST',
+      headers: _headers,
+      extra: _extra,
+    )
+        .compose(
+          _dio.options,
+          '/s/v1/chat/daily/keyword',
+          queryParameters: queryParameters,
+          data: _data,
+        )
+        .copyWith(
+            baseUrl: _combineBaseUrls(
+          _dio.options.baseUrl,
+          baseUrl,
+        )));
+    final _result = await _dio.fetch<Map<String, dynamic>>(_options);
+    late BaseResponse<DailyKeywordResponse> _value;
+    try {
+      _value = BaseResponse<DailyKeywordResponse>.fromJson(
+        _result.data!,
+        (json) => DailyKeywordResponse.fromJson(json as Map<String, dynamic>),
+      );
+    } on Object catch (e, s) {
+      errorLogger?.logError(e, s, _options);
+      rethrow;
+    }
+    return _value;
+  }
+
+  @override
+  Future<BaseResponse<TrackDailyInterpretResponse>> trackDailyInterpret(
+      QueryTrackRequest request) async {
+    final _extra = <String, dynamic>{};
+    final queryParameters = <String, dynamic>{};
+    final _headers = <String, dynamic>{};
+    final _data = <String, dynamic>{};
+    _data.addAll(request.toJson());
+    final _options =
+        _setStreamType<BaseResponse<TrackDailyInterpretResponse>>(Options(
+      method: 'POST',
+      headers: _headers,
+      extra: _extra,
+    )
+            .compose(
+              _dio.options,
+              '/s/v1/location/track/daily/interpret',
+              queryParameters: queryParameters,
+              data: _data,
+            )
+            .copyWith(
+                baseUrl: _combineBaseUrls(
+              _dio.options.baseUrl,
+              baseUrl,
+            )));
+    final _result = await _dio.fetch<Map<String, dynamic>>(_options);
+    late BaseResponse<TrackDailyInterpretResponse> _value;
+    try {
+      _value = BaseResponse<TrackDailyInterpretResponse>.fromJson(
+        _result.data!,
+        (json) =>
+            TrackDailyInterpretResponse.fromJson(json as Map<String, dynamic>),
+      );
+    } on Object catch (e, s) {
+      errorLogger?.logError(e, s, _options);
+      rethrow;
+    }
+    return _value;
+  }
+
   RequestOptions newRequestOptions(Object? options) {
     if (options is RequestOptions) {
       return options as RequestOptions;

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

@@ -52,6 +52,37 @@ class _AtmobStreamApi implements AtmobStreamApi {
     return _result.data!;
   }
 
+  @override
+  Future<ResponseBody> dailyExceptionAnalyse(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/exception/analyse',
+              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 ||

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

@@ -12,4 +12,8 @@ abstract class AtmobStreamApi {
   @POST("/s/v1/chat/daily/summary")
   @DioResponseType(ResponseType.stream)
   Future<ResponseBody> dailySummary(@Body() QueryTrackRequest request);
+
+  @POST("/s/v1/chat/daily/exception/analyse")
+  @DioResponseType(ResponseType.stream)
+  Future<ResponseBody> dailyExceptionAnalyse(@Body() QueryTrackRequest request);
 }

+ 14 - 0
lib/data/api/response/daily_keyword_response.dart

@@ -0,0 +1,14 @@
+import 'package:json_annotation/json_annotation.dart';
+
+part 'daily_keyword_response.g.dart';
+
+@JsonSerializable()
+class DailyKeywordResponse {
+  @JsonKey(name: 'list')
+  List<String>? list;
+
+  DailyKeywordResponse({this.list});
+
+  factory DailyKeywordResponse.fromJson(Map<String, dynamic> json) =>
+      _$DailyKeywordResponseFromJson(json);
+}

+ 19 - 0
lib/data/api/response/daily_keyword_response.g.dart

@@ -0,0 +1,19 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'daily_keyword_response.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+DailyKeywordResponse _$DailyKeywordResponseFromJson(
+        Map<String, dynamic> json) =>
+    DailyKeywordResponse(
+      list: (json['list'] as List<dynamic>?)?.map((e) => e as String).toList(),
+    );
+
+Map<String, dynamic> _$DailyKeywordResponseToJson(
+        DailyKeywordResponse instance) =>
+    <String, dynamic>{
+      'list': instance.list,
+    };

+ 16 - 0
lib/data/api/response/track_daily_interpret_response.dart

@@ -0,0 +1,16 @@
+import 'package:json_annotation/json_annotation.dart';
+
+import '../../bean/track_daily_bean.dart';
+
+part 'track_daily_interpret_response.g.dart';
+
+@JsonSerializable()
+class TrackDailyInterpretResponse {
+  @JsonKey(name: 'list')
+  List<TrackDailyBean>? list;
+
+  TrackDailyInterpretResponse({this.list});
+
+  factory TrackDailyInterpretResponse.fromJson(Map<String, dynamic> json) =>
+      _$TrackDailyInterpretResponseFromJson(json);
+}

+ 21 - 0
lib/data/api/response/track_daily_interpret_response.g.dart

@@ -0,0 +1,21 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+
+part of 'track_daily_interpret_response.dart';
+
+// **************************************************************************
+// JsonSerializableGenerator
+// **************************************************************************
+
+TrackDailyInterpretResponse _$TrackDailyInterpretResponseFromJson(
+        Map<String, dynamic> json) =>
+    TrackDailyInterpretResponse(
+      list: (json['list'] as List<dynamic>?)
+          ?.map((e) => TrackDailyBean.fromJson(e as Map<String, dynamic>))
+          .toList(),
+    );
+
+Map<String, dynamic> _$TrackDailyInterpretResponseToJson(
+        TrackDailyInterpretResponse instance) =>
+    <String, dynamic>{
+      'list': instance.list,
+    };

+ 50 - 0
lib/data/repositories/track_repository.dart

@@ -155,4 +155,54 @@ class TrackRepository {
       throw Exception('Invalid content type');
     }).then((stream) => SSEParseUtil.parse(stream));
   }
+
+  Future<List<String>?> dailyKeyword(
+      {required int? startTime,
+      required int? endTime,
+      required String? userId}) {
+    return atmobApi
+        .dailyKeyword(QueryTrackRequest(
+            startTime: startTime, endTime: endTime, userId: userId))
+        .then(HttpHandler.handle(true))
+        .then((response) => response.list);
+  }
+
+  Future<List<TrackDailyBean>?> trackDailyInterpret(
+      {required int? startTime,
+      required int? endTime,
+      required String? userId}) {
+    return atmobApi
+        .trackDailyInterpret(QueryTrackRequest(
+            startTime: startTime, endTime: endTime, userId: userId))
+        .then(HttpHandler.handle(true))
+        .then((response) => response.list);
+  }
+
+  Future<Stream<Message>> streamDailyExceptionAnalyse(
+      {required int? startTime,
+      required int? endTime,
+      required String? userId}) {
+    return streamApi
+        .dailyExceptionAnalyse(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));
+  }
 }

+ 3 - 0
lib/di/get_it.config.dart

@@ -26,6 +26,7 @@ import '../data/repositories/urgent_contact_repository.dart' as _i983;
 import '../helper/internet_connection_helper.dart' as _i772;
 import '../module/about/about_controller.dart' as _i256;
 import '../module/add_friend/add_friend_dialog_controller.dart' as _i897;
+import '../module/analyse/location_analyse_controller.dart' as _i783;
 import '../module/browser/browser_controller.dart' as _i923;
 import '../module/feedback/feed_back_controller.dart' as _i769;
 import '../module/friend/friend_controller.dart' as _i821;
@@ -144,6 +145,8 @@ extension GetItInjectableX on _i174.GetIt {
           gh<_i1053.FriendsRepository>(),
           gh<_i20.AccountRepository>(),
         ));
+    gh.factory<_i783.LocationAnalyseController>(
+        () => _i783.LocationAnalyseController(gh<_i240.TrackRepository>()));
     gh.lazySingleton<_i683.TodayTrackHelper>(
         () => _i683.TodayTrackHelper(gh<_i240.TrackRepository>()));
     gh.factory<_i269.MemberController>(() => _i269.MemberController(

+ 245 - 6
lib/module/analyse/location_analyse_controller.dart

@@ -1,35 +1,274 @@
+import 'dart:async';
+import 'dart:convert';
 import 'package:flutter/cupertino.dart';
+import 'package:flutter_map/flutter_map.dart';
 import 'package:get/get.dart';
 import 'package:get/get_core/src/get_main.dart';
+import 'package:injectable/injectable.dart';
 import 'package:location/base/base_controller.dart';
+import 'package:location/data/bean/user_info.dart';
+import 'package:location/data/repositories/track_repository.dart';
+import 'package:location/sdk/map/map_helper.dart';
+import 'package:location/utils/atmob_log.dart';
+import 'package:lottie/lottie.dart';
 import 'package:video_player/video_player.dart';
-
+import '../../data/bean/stream_chat_origin_data.dart';
+import '../../data/bean/track_daily_bean.dart';
 import '../../resource/assets.gen.dart';
+import '../../resource/string.gen.dart';
+import '../../utils/http_handler.dart';
+import '../../widget/gradually_print_text.dart';
+import 'location_analyse_util.dart';
 
-class LocationAnalyseController extends BaseController {
-  late final VideoPlayerController bgController;
+@injectable
+class LocationAnalyseController extends BaseController
+    with GetTickerProviderStateMixin {
+  late final VideoPlayerController locaController;
 
   final RxBool _videoReady = RxBool(false);
 
+  UserInfo? userInfo;
+  TrackDailyBean? errorInfo;
+
+  RxList<TrackDailyBean> errorAddr = RxList();
+  final RxBool _isShowAnalyseAddr = RxBool(false);
+
+  bool get isShowAnalyseAddr => _isShowAnalyseAddr.value;
+
+  final RxBool _showAnalyseRemainContent = RxBool(false);
+
+  bool get showAnalyseRemainContent => _showAnalyseRemainContent.value;
+
   bool get videoReady => _videoReady.value;
 
+  StreamSubscription? _streamSubscription;
+
+  final GraduallyController graduallyController = GraduallyController();
+
+  final RxBool _isShowAnalyseResult = RxBool(false);
+
+  bool get isShowAnalyseResult => _isShowAnalyseResult.value;
+
+  final RxBool _isRequestedAnalyse = RxBool(false);
+
+  bool get isRequestedAnalyse => _isRequestedAnalyse.value;
+
+  bool _isRequestingStream = false;
+
+  final RxnString _summaryError = RxnString();
+
+  String? get summaryError => _summaryError.value;
+
+  final Rxn<LottieDelegates> _keywordDelegates = Rxn<LottieDelegates>();
+
+  LottieDelegates? get keywordDelegates => _keywordDelegates.value;
+
+  final TrackRepository trackRepository;
+
+  late AnimationController keywordLottieController =
+      AnimationController(vsync: this);
+
+  Timer? _loopTimer;
+  bool _triggerFinalLoop = false;
+
+  //视频播放总长度 8175毫秒
+  Duration videoTotalDuration = const Duration(milliseconds: 8175);
+  Duration loopStart = const Duration(milliseconds: 5670);
+  Duration loopEnd = const Duration(milliseconds: 6570);
+
+  LocationAnalyseController(this.trackRepository);
+
   @override
   void onInit() {
     super.onInit();
-    bgController = VideoPlayerController.asset(
+    _initArgs();
+    _getKeyword();
+    _getErrorAddr();
+
+    graduallyController.setGraduallyFinishedListener(() {
+      _isRequestedAnalyse.value = true;
+      _isRequestingStream = false;
+    });
+
+    locaController = VideoPlayerController.asset(
       Assets.anim.locationAnalyseRobot,
     )
-      ..setLooping(true)
       ..setVolume(0.0)
       ..initialize().then((_) {
         _videoReady.value = true;
-        bgController.play();
+        locaController.play();
+        AtmobLog.d('zk', "duration:${locaController.value.duration}");
+        _startMonitorLoop();
       }).catchError((error) {
         debugPrint('Error initializing video: $error');
       });
   }
 
+  void _startMonitorLoop() {
+    _loopTimer =
+        Timer.periodic(const Duration(milliseconds: 100), (timer) async {
+      final position = await locaController.position;
+      if (position == null) return;
+
+      // 第一阶段:0~6s,播放完成后进入循环
+      if (!_triggerFinalLoop) {
+        if (position >= loopEnd) {
+          // 循环 4~6s
+          locaController.seekTo(loopStart);
+        }
+      } else {
+        // 第二阶段:触发后进入 6~8s 循环
+        if (loopStart != const Duration(seconds: 6)) {
+          loopStart = const Duration(seconds: 6);
+          loopEnd = videoTotalDuration;
+        }
+        if (position >= loopEnd) {
+          locaController.seekTo(loopStart);
+        }
+      }
+    });
+  }
+
+  void _getErrorAddr() {
+    trackRepository
+        .trackDailyInterpret(
+            startTime: errorInfo?.start,
+            endTime: errorInfo?.end,
+            userId: userInfo?.id)
+        .then((list) {
+      errorAddr.assignAll(list ?? []);
+    });
+  }
+
+  void _getKeyword() {
+    trackRepository
+        .dailyKeyword(
+            startTime: errorInfo?.start,
+            endTime: errorInfo?.end,
+            userId: userInfo?.id)
+        .then((list) {
+      //填充分析异常点到lottie占位中,有6个占位点,list如果小于则循环显示,如果超过6个,则随机6个选择,但不能重复出现
+      if (list != null && list.isNotEmpty) {
+        _keywordDelegates.value =
+            LocationAnalyseUtil.convertKeywordDelegates(list);
+      }
+    });
+  }
+
+  void _initArgs() {
+    final info = parameters?['userInfo'];
+    if (info is UserInfo) {
+      userInfo = info;
+    }
+    final errorInfo = parameters?['errorData'];
+    if (errorInfo is TrackDailyBean) {
+      this.errorInfo = errorInfo;
+    }
+  }
+
   void back() {
     Get.back();
   }
+
+  void onTrackRefreshClick() {
+    _analyseErrorAddr();
+  }
+
+  String getErrorDistance(TrackDailyBean errorAddr) {
+    final lastLocation = MapHelper.getLastLocation();
+    if (lastLocation == null ||
+        errorAddr.lat == null ||
+        errorAddr.lng == null) {
+      return StringName.unopenedPositioning;
+    }
+    final distance = MapUtil.calculateLineDistance(lastLocation.longitude,
+        lastLocation.latitude, errorAddr.lng!, errorAddr.lat!);
+    return MapUtil.format(distance);
+  }
+
+  void onAnalyseTextComplete() {
+    _isShowAnalyseAddr.value = true;
+    Future.delayed(Duration(milliseconds: 700), () {
+      _showAnalyseRemainContent.value = true;
+    });
+  }
+
+  void onAnalyseFinishComplete() {
+    _isShowAnalyseResult.value = true;
+    _analyseErrorAddr();
+  }
+
+  void _analyseErrorAddr() {
+    //准备调用分析总结
+    if (_isRequestingStream) {
+      return;
+    }
+
+    _isRequestedAnalyse.value = false;
+    _isRequestingStream = true;
+    graduallyController.clear();
+    _summaryError.value = null;
+
+    trackRepository
+        .streamDailyExceptionAnalyse(
+            startTime: errorInfo?.start,
+            endTime: errorInfo?.end,
+            userId: userInfo?.id)
+        .then((stream) {
+      _streamSubscription?.cancel();
+      _streamSubscription = 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();
+        _setAnalyseSuccess();
+      }, onError: (error) {
+        _summaryError.value = "网络错误,请检查网络连接";
+        debugPrint("error: $error");
+        _isRequestedAnalyse.value = false;
+        _isRequestingStream = false;
+        debugPrintStack();
+      });
+    }).catchError((error) {
+      _isRequestedAnalyse.value = false;
+      _isRequestingStream = false;
+      if (error is ServerErrorException) {
+        _summaryError.value = error.message ?? "服务出错,请稍后再试";
+      } else {
+        _summaryError.value = "网络错误,请检查网络连接";
+        debugPrint("error: $error");
+        debugPrintStack();
+      }
+    });
+  }
+
+  void locationKeywordLottieLoad(LottieComposition composition) async {
+    keywordLottieController.duration = composition.duration;
+    await keywordLottieController.animateTo(0.84);
+    keywordLottieController.repeat(min: 0, max: 0.84, reverse: true);
+  }
+
+  void _setAnalyseSuccess() {
+    keywordLottieController.animateTo(1);
+  }
+
+  @override
+  void onClose() {
+    _streamSubscription?.cancel();
+    keywordLottieController.dispose();
+    super.onClose();
+  }
 }

+ 298 - 15
lib/module/analyse/location_analyse_page.dart

@@ -5,22 +5,31 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
 import 'package:get/get.dart';
 import 'package:get/get_core/src/get_main.dart';
 import 'package:location/base/base_page.dart';
+import 'package:location/data/bean/track_daily_bean.dart';
+import 'package:location/data/bean/user_info.dart';
 import 'package:location/resource/colors.gen.dart';
+import 'package:location/resource/string.gen.dart';
 import 'package:location/router/app_pages.dart';
 import 'package:location/utils/common_expand.dart';
 import 'package:lottie/lottie.dart';
 import 'package:video_player/video_player.dart';
 import '../../resource/assets.gen.dart';
+import '../../utils/common_style.dart';
+import '../../widget/animated_visibility.dart';
 import '../../widget/common_view.dart';
+import '../../widget/gradually_print_text.dart';
+import '../../widget/typewriter_text.dart';
 import 'location_analyse_controller.dart';
 
 class LocationAnalysePage extends BasePage<LocationAnalyseController> {
-  LocationAnalysePage({super.key}) {
-    Get.lazyPut(() => LocationAnalyseController(), fenix: true);
-  }
+  const LocationAnalysePage({super.key});
 
-  static void start() {
-    Get.toNamed(RoutePath.locationAnalyse);
+  static void start(
+      {required TrackDailyBean errorData, required UserInfo userInfo}) {
+    Get.toNamed(RoutePath.locationAnalyse, arguments: {
+      'errorData': errorData,
+      'userInfo': userInfo,
+    });
   }
 
   @override
@@ -52,22 +61,296 @@ class LocationAnalysePage extends BasePage<LocationAnalyseController> {
           child: Column(
         children: [
           buildAIAnalyseView(),
+          SizedBox(height: 10.w),
+          buildTrackAnalyseHeaderView(),
+          SizedBox(height: 8.w),
+          buildAnalyseResultView(),
+          SizedBox(height: 30.w),
         ],
       )),
     );
   }
 
+  Widget buildAnalyseResultView() {
+    return Obx(() {
+      return AnimatedOpacity(
+        duration: const Duration(milliseconds: 500),
+        opacity: controller.isShowAnalyseResult ? 1.0 : 0.0,
+        child: Container(
+          padding:
+              EdgeInsets.only(left: 11.w, right: 11.w, top: 20.w, bottom: 20.w),
+          margin: EdgeInsets.symmetric(horizontal: 10.w),
+          decoration: BoxDecoration(
+            color: ColorName.white,
+            borderRadius: BorderRadius.circular(12.w),
+          ),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              Row(
+                children: [
+                  Assets.images.iconTrackDailySummary.image(width: 20.w),
+                  SizedBox(width: 4.w),
+                  Text(StringName.trackAnalyseResultTitle,
+                      style: TextStyle(
+                          fontSize: 13.sp,
+                          color: '#333333'.color,
+                          fontWeight: FontWeight.bold))
+                ],
+              ),
+              SizedBox(height: 10.w),
+              Obx(() {
+                if (controller.summaryError?.isNotEmpty == true) {
+                  return Text(controller.summaryError!,
+                      style:
+                          TextStyle(fontSize: 12.sp, color: '#FF0000'.color));
+                } else {
+                  return GraduallyPrintText(
+                    graduallyController: controller.graduallyController,
+                    textStyle:
+                        TextStyle(fontSize: 12.sp, color: '#333333'.color),
+                  );
+                }
+              }),
+            ],
+          ),
+        ),
+      );
+    });
+  }
+
+  Widget buildTrackAnalyseHeaderView() {
+    return SizedBox(
+      width: double.infinity,
+      child: Stack(
+        children: [
+          buildHeaderContentView(),
+          Positioned(
+            top: 8.w,
+            left: 20.w,
+            child: Container(
+              width: 64.w,
+              height: 64.w,
+              padding: EdgeInsets.all(2.w),
+              decoration: BoxDecoration(
+                shape: BoxShape.circle,
+                color: ColorName.white,
+              ),
+              child: buildCustomAvatarOrDefaultAvatarView(
+                size: double.infinity,
+                avatar: controller.userInfo?.avatar,
+                isMine: controller.userInfo?.isMine == true,
+              ),
+            ),
+          ),
+          buildAnalyseBtn(),
+        ],
+      ),
+    );
+  }
+
+  Widget buildHeaderContentView() {
+    return Container(
+      width: double.infinity,
+      margin: EdgeInsets.only(left: 10.w, right: 10.w, top: 25.w),
+      decoration: BoxDecoration(
+          color: ColorName.white, borderRadius: BorderRadius.circular(12.w)),
+      child: Column(
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: [
+          Container(
+            margin: EdgeInsets.only(left: 85.w, top: 8.w),
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                Text(
+                  controller.userInfo?.getUserNickName() ?? '',
+                  style: TextStyle(
+                      fontSize: 14.sp,
+                      color: '#333333'.color,
+                      fontWeight: FontWeight.bold),
+                ),
+                SizedBox(height: 3.w),
+                Text(
+                  controller.errorInfo?.addr == null
+                      ? StringName.trackAnalyseNoNewPosition
+                      : '异常地点:${controller.errorInfo?.addr}',
+                  style: TextStyle(fontSize: 12.sp, color: '#F24D4D'.color),
+                )
+              ],
+            ),
+          ),
+          SizedBox(height: 20.w),
+          Container(
+            margin: EdgeInsets.only(left: 14.w),
+            child: Row(
+              children: [
+                Assets.images.iconTrackAiInterpretation
+                    .image(width: 16.w, height: 16.w),
+                SizedBox(width: 6.w),
+                Text(
+                  StringName.trackAnalyseAiInterpretation,
+                  style: TextStyle(
+                      fontSize: 14.sp,
+                      color: '#333333'.color,
+                      fontWeight: FontWeight.bold),
+                )
+              ],
+            ),
+          ),
+          SizedBox(height: 10.w),
+          Padding(
+            padding: EdgeInsets.symmetric(horizontal: 12.w),
+            child: TypewriterText(
+              text: StringName.trackAnalyseAiInterpretationDetails,
+              style: TextStyle(fontSize: 11.sp, color: '#666666'.color),
+              onComplete: controller.onAnalyseTextComplete,
+            ),
+          ),
+          SizedBox(height: 4.w),
+          buildAnalyseErrorAddressView(),
+          SizedBox(height: 4.w),
+          Obx(() {
+            return Visibility(
+              visible: controller.showAnalyseRemainContent,
+              child: Padding(
+                padding: EdgeInsets.symmetric(horizontal: 12.w),
+                child: TypewriterText(
+                  text: StringName.trackAnalyseAiInterpretationDetailsFinally,
+                  style: TextStyle(fontSize: 11.sp, color: '#666666'.color),
+                  onComplete: controller.onAnalyseFinishComplete,
+                ),
+              ),
+            );
+          }),
+          SizedBox(height: 12.w),
+        ],
+      ),
+    );
+  }
+
+  Widget buildAnalyseErrorAddressView() {
+    return Obx(() {
+      return AnimatedVisibility(
+        duration: const Duration(milliseconds: 600),
+        visible: controller.isShowAnalyseAddr,
+        child: Column(
+          children: [
+            for (int i = 0; i < controller.errorAddr.length; i++) ...[
+              Container(
+                padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 8.w),
+                child: Row(
+                  children: [
+                    Expanded(
+                      child: Text(
+                        controller.errorAddr[i].addr ?? '',
+                        style:
+                            TextStyle(fontSize: 11.sp, color: '#666666'.color),
+                      ),
+                    ),
+                    SizedBox(width: 16.w),
+                    Text(controller.getErrorDistance(controller.errorAddr[i]),
+                        style:
+                            TextStyle(fontSize: 11.sp, color: '#666666'.color))
+                  ],
+                ),
+              ),
+              if (i < controller.errorAddr.length - 1)
+                Container(width: 316.w, height: 1.w, color: '#EEEEEE'.color),
+            ]
+          ],
+        ),
+      );
+    });
+  }
+
+  Widget buildAnalyseBtn() {
+    return Positioned(
+      top: 0,
+      right: 10.w,
+      child: GestureDetector(
+        onTap: controller.onTrackRefreshClick,
+        child: Container(
+          padding: EdgeInsets.symmetric(horizontal: 11.w, vertical: 4.w),
+          child: IntrinsicWidth(
+            child: Row(
+              children: [
+                Assets.images.iconTrackAnalyseRefresh.image(width: 12.w),
+                SizedBox(width: 3.w),
+                Text(
+                  StringName.trackAnalyseRefresh,
+                  style: TextStyle(
+                    fontSize: 12.sp,
+                    color: '#333333'.color,
+                  ),
+                ),
+              ],
+            ),
+          ),
+        ),
+      ),
+    );
+  }
+
   Widget buildAIAnalyseView() {
     return Stack(
-      alignment: Alignment.center,
       children: [
-        Assets.images.bgLocationAnalyse.image(width: double.infinity),
-        buildRobotView(),
-        Lottie.asset(
-          Assets.anim.locationLabel,
-          delegates: LottieDelegates(
-            values: [
-              ValueDelegate.text(['健身房', '健身房'], value: '占位符啊123')
+        Container(
+            margin: EdgeInsets.only(bottom: 40.w),
+            child:
+                Assets.images.bgLocationAnalyse.image(width: double.infinity)),
+        SafeArea(
+            child: SizedBox(
+          width: double.infinity,
+          height: 317.w,
+          child: Stack(
+            alignment: Alignment.center,
+            children: [
+              buildRobotView(),
+              Obx(() {
+                return Visibility(
+                  visible: controller.keywordDelegates != null,
+                  child: Lottie.asset(
+                      controller: controller.keywordLottieController,
+                      Assets.anim.locationLabel,
+                      onLoaded: controller.locationKeywordLottieLoad,
+                      delegates: controller.keywordDelegates),
+                );
+              }),
+            ],
+          ),
+        )),
+        Positioned(
+          bottom: 10.w,
+          left: 0,
+          right: 0,
+          child: Column(
+            children: [
+              Obx(() {
+                return Text(
+                  controller.isRequestedAnalyse
+                      ? StringName.trackAnalyseDone
+                      : (controller.errorInfo?.addr == null
+                          ? StringName.trackAnalysing
+                          : StringName.trackErrorAddrAnalysing),
+                  style: TextStyle(
+                      fontSize: 16.sp,
+                      color: '#333333'.color,
+                      fontWeight: FontWeight.bold),
+                );
+              }),
+              SizedBox(height: 10.w),
+              Obx(() {
+                return Text(
+                  controller.isRequestedAnalyse
+                      ? StringName.trackAnalyseDoneDetail
+                      : StringName.trackAnalysingTips,
+                  style: TextStyle(
+                      fontSize: 13.sp,
+                      color: '#9D96AD'.color,
+                      fontWeight: FontWeight.bold),
+                );
+              })
             ],
           ),
         )
@@ -90,11 +373,11 @@ class LocationAnalysePage extends BasePage<LocationAnalyseController> {
                 height: 144.w,
                 child: Obx(() {
                   if (!controller.videoReady) {
-                    return Center(child: Text('视频还没准备好'));
+                    return SizedBox.shrink();
                   }
                   return Transform.scale(
                     scale: 1.012, // 放大 3%,可以试试 1.01 ~ 1.05
-                    child: VideoPlayer(controller.bgController),
+                    child: VideoPlayer(controller.locaController),
                   );
                 }),
               ),

+ 37 - 0
lib/module/analyse/location_analyse_util.dart

@@ -0,0 +1,37 @@
+import 'dart:math';
+
+import 'package:lottie/lottie.dart';
+
+class LocationAnalyseUtil {
+  static LottieDelegates convertKeywordDelegates(List<String> list) {
+    const int placeholderCount = 6;
+
+    // 占位 keyPath 名称,例如 keyword1, keyword2, ..., keyword6
+    final List<String> keyPaths =
+        List.generate(placeholderCount, (index) => 'keyword${index + 1}');
+
+    final List<String> finalTexts;
+
+    if (list.length < placeholderCount) {
+      // 不足 6 个,循环填充
+      finalTexts =
+          List.generate(placeholderCount, (index) => list[index % list.length]);
+    } else {
+      // 超过 6 个,随机取 6 个不重复
+      final List<String> shuffled = List.from(list)..shuffle(Random());
+      finalTexts = shuffled.take(placeholderCount).toList();
+    }
+
+    // 构建 Lottie 的 ValueDelegate 列表
+    final List<ValueDelegate> valueDelegates =
+        List.generate(placeholderCount, (index) {
+      return ValueDelegate.text(
+        [keyPaths[index], keyPaths[index]],
+        value: finalTexts[index],
+      );
+    });
+
+    // 更新绑定
+    return LottieDelegates(values: valueDelegates);
+  }
+}

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

@@ -3,6 +3,7 @@ import 'dart:math';
 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/bean/user_info.dart';
 import 'package:location/data/consts/constants.dart';
 import 'package:location/dialog/track_error_tips_dialog.dart';
 import 'package:location/module/analyse/location_analyse_page.dart';
@@ -16,9 +17,10 @@ import 'package:location/utils/date_util.dart';
 import '../track_status.dart';
 
 typedef TrackItemClick = void Function(TrackDailyBean bean);
+typedef TrackAIAnalyseClick = void Function(TrackDailyBean bean);
 
 Widget buildTrackDailyItem(TrackDailyBean bean, bool isEnd,
-    {TrackItemClick? onItemClick}) {
+    {TrackItemClick? onItemClick, TrackAIAnalyseClick? onAIAnalyseClick}) {
   return Container(
     padding: EdgeInsets.symmetric(horizontal: 12.w),
     margin: EdgeInsets.only(bottom: 8.w),
@@ -31,7 +33,8 @@ Widget buildTrackDailyItem(TrackDailyBean bean, bool isEnd,
           } else if (bean.status == TrackStatus.stay) {
             return buildStayTrackDailyItem(bean, onItemClick: onItemClick);
           } else if (bean.status == TrackStatus.error) {
-            return buildErrorTrackDailyItem(bean, onItemClick: onItemClick);
+            return buildErrorTrackDailyItem(bean,
+                onItemClick: onItemClick, onAIAnalyseClick: onAIAnalyseClick);
           } else {
             return SizedBox(height: 50.w, child: Text('未知轨迹,请更新最新应用版本'));
           }
@@ -184,7 +187,9 @@ Widget buildStayTrackDailyItem(TrackDailyBean bean,
 }
 
 Widget buildErrorTrackDailyItem(TrackDailyBean bean,
-    {EdgeInsetsGeometry? contentPadding, TrackItemClick? onItemClick}) {
+    {EdgeInsetsGeometry? contentPadding,
+    TrackItemClick? onItemClick,
+    TrackAIAnalyseClick? onAIAnalyseClick}) {
   return Column(
     crossAxisAlignment: CrossAxisAlignment.start,
     children: [
@@ -248,7 +253,7 @@ Widget buildErrorTrackDailyItem(TrackDailyBean bean,
                     Spacer(),
                     GestureDetector(
                         onTap: () {
-                          LocationAnalysePage.start();
+                          onAIAnalyseClick?.call(bean);
                         },
                         child:
                             Assets.images.imgTrackAiAnalyse.image(width: 73.w)),

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

@@ -11,6 +11,7 @@ import 'package:location/data/consts/constants.dart';
 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/analyse/location_analyse_page.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/module/track/track_util.dart';
@@ -419,6 +420,14 @@ class TrackDayDetailController extends BaseController {
     }
   }
 
+  void onAIAnalyseClick(TrackDailyBean bean) {
+    final userInfo = trackController.userInfo;
+    if (userInfo == null) {
+      return;
+    }
+    LocationAnalysePage.start(errorData: bean, userInfo: userInfo);
+  }
+
   @override
   void onClose() {
     super.onClose();

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

@@ -285,7 +285,8 @@ class TrackDayDetailView extends BaseView<TrackDayDetailController> {
                 return buildErrorTrackDailyItem(
                     controller.expandSituation!.second,
                     contentPadding: EdgeInsets.only(top: 46.w, bottom: 12.w),
-                    onItemClick: controller.onHistoryTrackItemClick);
+                    onItemClick: controller.onHistoryTrackItemClick,
+                    onAIAnalyseClick: controller.onAIAnalyseClick);
               } else {
                 return buildStayTrackDailyItem(
                     controller.expandSituation!.second,
@@ -418,6 +419,7 @@ class TrackDayDetailView extends BaseView<TrackDayDetailController> {
   Widget buildHistoryTrackItem(BuildContext context, int index) {
     return buildTrackDailyItem(controller.trackDailyList[index],
         index == controller.trackDailyList.length - 1,
-        onItemClick: controller.onHistoryTrackItemClick);
+        onItemClick: controller.onHistoryTrackItemClick,
+        onAIAnalyseClick: controller.onAIAnalyseClick);
   }
 }

+ 12 - 2
lib/resource/assets.gen.dart

@@ -15,8 +15,8 @@ class $AssetsAnimGen {
   /// File path: assets/anim/location_analyse_robot.mp4
   String get locationAnalyseRobot => 'assets/anim/location_analyse_robot.mp4';
 
-  /// File path: assets/anim/location_label.json
-  String get locationLabel => 'assets/anim/location_label.json';
+  /// File path: assets/anim/location_label.zip
+  String get locationLabel => 'assets/anim/location_label.zip';
 
   /// List of all assets
   List<String> get values => [locationAnalyseRobot, locationLabel];
@@ -481,6 +481,14 @@ class $AssetsImagesGen {
   AssetGenImage get iconSplashTitle =>
       const AssetGenImage('assets/images/icon_splash_title.webp');
 
+  /// File path: assets/images/icon_track_ai_interpretation.webp
+  AssetGenImage get iconTrackAiInterpretation =>
+      const AssetGenImage('assets/images/icon_track_ai_interpretation.webp');
+
+  /// File path: assets/images/icon_track_analyse_refresh.webp
+  AssetGenImage get iconTrackAnalyseRefresh =>
+      const AssetGenImage('assets/images/icon_track_analyse_refresh.webp');
+
   /// File path: assets/images/icon_track_daily_report.webp
   AssetGenImage get iconTrackDailyReport =>
       const AssetGenImage('assets/images/icon_track_daily_report.webp');
@@ -741,6 +749,8 @@ class $AssetsImagesGen {
         iconNews,
         iconNewsItem,
         iconSplashTitle,
+        iconTrackAiInterpretation,
+        iconTrackAnalyseRefresh,
         iconTrackDailyReport,
         iconTrackDailySummary,
         iconTrackDailySummaryArrow,

+ 24 - 4
lib/resource/string.gen.dart

@@ -291,10 +291,19 @@ class StringName {
   static String get trackDailySkipCallPhoneFail => 'track_daily_skip_call_phone_fail'.tr; // 跳转拨号界面失败
   static String get mainTodayTrackLoading => 'main_today_track_loading'.tr; // 正在加载中...
   static String get mainTodayTrackNormalPoint => 'main_today_track_normal_point'.tr; // 暂无异常
-  static String get permissionPhoneScreenSetting =>
-      'permission_phone_screen_setting'.tr; // 手机屏幕使用时长
-  static String get permissionPhoneScreenSettingSubtitle =>
-      'permission_phone_screen_setting_subtitle'.tr; // 获取权限,保证您的信息定位更精准,预测更加准确。
+  static String get permissionPhoneScreenSetting => 'permission_phone_screen_setting'.tr; // 手机屏幕使用时长
+  static String get permissionPhoneScreenSettingSubtitle => 'permission_phone_screen_setting_subtitle'.tr; // 获取权限,保证您的信息定位更精准,预测更加准确。
+  static String get trackAnalysing => 'track_analysing'.tr; // 无新定位,loca分析中...
+  static String get trackErrorAddrAnalysing => 'track_error_addr_analysing'.tr; // 异常地点,loca分析中...
+  static String get trackAnalysingTips => 'track_analysing_tips'.tr; // 正在结合对方过往的常去动态定位分析中,请稍等...
+  static String get trackAnalyseRefresh => 'track_analyse_refresh'.tr; // 刷新
+  static String get trackAnalyseNoNewPosition => 'track_analyse_no_new_position'.tr; // 当前无对方新定位
+  static String get trackAnalyseAiInterpretation => 'track_analyse_ai_interpretation'.tr; // AI分析解读:
+  static String get trackAnalyseAiInterpretationDetails => 'track_analyse_ai_interpretation_details'.tr; // 好的,现在开始根据用户目前无新定位信息分析用户目前可能在的地方或者是因为其他原因导致无新定位,我们会根据用户之前长逗留的地方分享用户可能在:
+  static String get trackAnalyseAiInterpretationDetailsFinally => 'track_analyse_ai_interpretation_details_finally'.tr; // 根据用户过往定位分析得出。
+  static String get trackAnalyseResultTitle => 'track_analyse_result_title'.tr; // 分析结果总结:
+  static String get trackAnalyseDone => 'track_analyse_done'.tr; // 分析完毕
+  static String get trackAnalyseDoneDetail => 'track_analyse_done_detail'.tr; // 过往的常去动态定位分析完毕,请看下方
 }
 class StringMultiSource {
   StringMultiSource._();
@@ -591,6 +600,17 @@ class StringMultiSource {
       'main_today_track_normal_point': '暂无异常',
       'permission_phone_screen_setting': '手机屏幕使用时长',
       'permission_phone_screen_setting_subtitle': '获取权限,保证您的信息定位更精准,预测更加准确。',
+      'track_analysing': '无新定位,loca分析中...',
+      'track_error_addr_analysing': '异常地点,loca分析中...',
+      'track_analysing_tips': '正在结合对方过往的常去动态定位分析中,请稍等...',
+      'track_analyse_refresh': '刷新',
+      'track_analyse_no_new_position': '当前无对方新定位',
+      'track_analyse_ai_interpretation': 'AI分析解读:',
+      'track_analyse_ai_interpretation_details': '好的,现在开始根据用户目前无新定位信息分析用户目前可能在的地方或者是因为其他原因导致无新定位,我们会根据用户之前长逗留的地方分享用户可能在:',
+      'track_analyse_ai_interpretation_details_finally': '根据用户过往定位分析得出。',
+      'track_analyse_result_title': '分析结果总结:',
+      'track_analyse_done': '分析完毕',
+      'track_analyse_done_detail': '过往的常去动态定位分析完毕,请看下方',
     },
   };
 }

+ 2 - 0
lib/router/app_pages.dart

@@ -27,6 +27,7 @@ import 'package:location/module/urgent_contact/add_contact/add_urgent_contact_co
 import 'package:location/module/urgent_contact/urgent_contact_controller.dart';
 import 'package:location/module/urgent_contact/urgent_contact_page.dart';
 import '../module/add_friend/add_friend_dialog_controller.dart';
+import '../module/analyse/location_analyse_controller.dart';
 import '../module/login/login_page.dart';
 import '../module/main/main_controller.dart';
 import '../module/mine/mine_controller.dart';
@@ -80,6 +81,7 @@ class AppBinding extends Bindings {
     lazyPut(() => getIt.get<AboutController>());
     lazyPut(() => getIt.get<PermissionSettingController>());
     lazyPut(() => getIt.get<TrackDetailController>());
+    lazyPut(() => getIt.get<LocationAnalyseController>());
   }
 
   void lazyPut<S>(InstanceBuilderCallback<S> builder) {

+ 65 - 0
lib/widget/animated_visibility.dart

@@ -0,0 +1,65 @@
+import 'package:flutter/material.dart';
+
+class AnimatedVisibility extends StatefulWidget {
+  final bool visible;
+  final Widget child;
+  final Duration duration;
+  final Curve curve;
+
+  const AnimatedVisibility({
+    super.key,
+    required this.visible,
+    required this.child,
+    this.duration = const Duration(milliseconds: 400),
+    this.curve = Curves.easeInOut,
+  });
+
+  @override
+  State<AnimatedVisibility> createState() => _AnimatedVisibilityState();
+}
+
+class _AnimatedVisibilityState extends State<AnimatedVisibility>
+    with SingleTickerProviderStateMixin {
+  late final AnimationController _controller;
+  late final Animation<double> _fadeAnimation;
+
+  @override
+  void initState() {
+    super.initState();
+    _controller = AnimationController(
+      duration: widget.duration,
+      vsync: this,
+    );
+    _fadeAnimation = CurvedAnimation(parent: _controller, curve: widget.curve);
+
+    if (widget.visible) {
+      _controller.forward();
+    }
+  }
+
+  @override
+  void didUpdateWidget(covariant AnimatedVisibility oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (widget.visible != oldWidget.visible) {
+      widget.visible ? _controller.forward() : _controller.reverse();
+    }
+  }
+
+  @override
+  void dispose() {
+    _controller.dispose();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return SizeTransition(
+      sizeFactor: _fadeAnimation,
+      axisAlignment: -1.0, // 从上方展开
+      child: FadeTransition(
+        opacity: _fadeAnimation,
+        child: widget.child,
+      ),
+    );
+  }
+}

+ 10 - 0
lib/widget/gradually_print_text.dart

@@ -41,6 +41,16 @@ class GraduallyController {
     this.finishedListener = finishedListener;
   }
 
+  void clear() {
+    appendTimer?.cancel();
+    appendTimer = null;
+    graduallyTxt = '';
+    progressIndex = 0;
+    isAppendDone = null;
+    isShowDone = null;
+    listener?.call(''); // 通知 UI 清空
+  }
+
   append(String txt) {
     graduallyTxt += txt;
     _startAppend();

+ 69 - 0
lib/widget/typewriter_text.dart

@@ -0,0 +1,69 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+
+class TypewriterText extends StatefulWidget {
+  final String text; // 要显示的完整文本
+  final TextStyle? style; // 字体样式
+  final Duration speed; // 每个字出现的速度
+  final VoidCallback? onComplete; // 打字完成回调
+  final bool repeat; // 是否循环播放
+
+  const TypewriterText({
+    super.key,
+    required this.text,
+    this.style,
+    this.speed = const Duration(milliseconds: 80),
+    this.onComplete,
+    this.repeat = false,
+  });
+
+  @override
+  _TypewriterTextState createState() => _TypewriterTextState();
+}
+
+class _TypewriterTextState extends State<TypewriterText> {
+  String _displayedText = '';
+  int _charIndex = 0;
+  Timer? _timer;
+
+  @override
+  void initState() {
+    super.initState();
+    _startTyping();
+  }
+
+  void _startTyping() {
+    _displayedText = '';
+    _charIndex = 0;
+    _timer?.cancel();
+
+    _timer = Timer.periodic(widget.speed, (timer) {
+      if (_charIndex < widget.text.length) {
+        setState(() {
+          _displayedText += widget.text[_charIndex];
+        });
+        _charIndex++;
+      } else {
+        _timer?.cancel();
+        widget.onComplete?.call();
+        if (widget.repeat) {
+          Future.delayed(Duration(seconds: 1), _startTyping);
+        }
+      }
+    });
+  }
+
+  @override
+  void dispose() {
+    _timer?.cancel();
+    super.dispose();
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    return Text(
+      _displayedText,
+      style: widget.style ?? const TextStyle(fontSize: 18),
+    );
+  }
+}

+ 4 - 1
plugins/map/lib/flutter_map.dart

@@ -24,4 +24,7 @@ export 'package:flutter_map/src/entity/polyline.dart';
 
 //接口
 export 'package:flutter_map/src/interface/map_sdk_interface.dart';
-export 'package:flutter_map/src/interface/map_marker_interface.dart';
+export 'package:flutter_map/src/interface/map_marker_interface.dart';
+
+//工具类
+export 'package:flutter_map/src/utils/map_util.dart';

+ 79 - 0
plugins/map/lib/src/utils/map_util.dart

@@ -0,0 +1,79 @@
+import 'dart:math';
+
+import '../../flutter_map.dart';
+
+class MapUtil {
+  MapUtil._();
+
+  static double calculateLineLatLngDistance(LatLng p1, LatLng p2) {
+    if (p1.latitude == null ||
+        p1.longitude == null ||
+        p2.latitude == null ||
+        p2.longitude == null) {
+      throw ArgumentError("Invalid coordinates");
+    }
+
+    return calculateLineDistance(
+        p1.longitude!, p1.latitude!, p2.longitude!, p2.latitude!);
+  }
+
+  static double calculateLineDistance(double longitudeP1, double latitudeP1,
+      double longitudeP2, double latitudeP2) {
+    try {
+      // 转为弧度
+      double lon1 = longitudeP1 * (pi / 180.0);
+      double lat1 = latitudeP1 * (pi / 180.0);
+      double lon2 = longitudeP2 * (pi / 180.0);
+      double lat2 = latitudeP2 * (pi / 180.0);
+
+      double sinLon1 = sin(lon1);
+      double sinLat1 = sin(lat1);
+      double cosLon1 = cos(lon1);
+      double cosLat1 = cos(lat1);
+
+      double sinLon2 = sin(lon2);
+      double sinLat2 = sin(lat2);
+      double cosLon2 = cos(lon2);
+      double cosLat2 = cos(lat2);
+
+      // 三维向量表示
+      List<double> v1 = [
+        cosLat1 * cosLon1,
+        cosLat1 * sinLon1,
+        sinLat1,
+      ];
+
+      List<double> v2 = [
+        cosLat2 * cosLon2,
+        cosLat2 * sinLon2,
+        sinLat2,
+      ];
+
+      // 计算向量差的模长
+      double dx = v1[0] - v2[0];
+      double dy = v1[1] - v2[1];
+      double dz = v1[2] - v2[2];
+
+      double chordLength = sqrt(dx * dx + dy * dy + dz * dz);
+
+      // 球面线距离(地球平均半径约 6371000m)
+      return asin(chordLength / 2.0) * 12742001.5798544;
+    } catch (e) {
+      print("Error in calculateLineDistance: $e");
+      return 0.0;
+    }
+  }
+
+  static String format(double distanceInMeters) {
+    if (distanceInMeters < 1000) {
+      return '${distanceInMeters.round()}m';
+    } else {
+      double km = distanceInMeters / 1000;
+      String kmStr = km.toStringAsFixed(1);
+      if (kmStr.endsWith('.0')) {
+        kmStr = kmStr.substring(0, kmStr.length - 2); // 去掉 .0
+      }
+      return '${kmStr}km';
+    }
+  }
+}