package_flutter_env 0.0.29+1 copy "package_flutter_env: ^0.0.29+1" to clipboard
package_flutter_env: ^0.0.29+1 copied to clipboard

Using MIAppBarWidget to create a custom app bar.

Custom script for generating environment-specific code for different platforms #

Features #

The flutter_env.dart file appears to be a custom script for generating environment-specific code for different platforms (Dart, Objective-C, and Gradle for Android). It reads from an environment file (.env by default), and generates code based on the key-value pairs in the environment file.

Here's a basic usage guide:

  1. Create an environment file: Create a .env file in your project root (or specify a different file using the envfile argument). This file should contain key-value pairs, one per line, like this:
    API_KEY=123456
    BASE_URL=https://api.example.com

2.Run the script: You can run the DotENV class with the command-line arguments. For example:

    void main(List<String> args) {
    DotENV(args);
    }

You can pass arguments to specify the platform (platform), the environment file (envfile), and the directory name (dirname). If not specified, it will use default values.

3.Generated code: The script will generate a Dart file (lib/env.dart by default) with a class ENV that contains static string properties for each key-value pair in the environment file. For example:

    class ENV {
        static String API_KEY = "123456";
        static String BASE_URL = "https://api.example.com";
    }

You can then import this file in your Flutter code and access the environment variables like this: ENV.API_KEY.

Please note that this is a basic guide and the actual usage may vary depending on your project setup and requirements. Also, remember to exclude your environment files from version control to avoid committing sensitive data.

Unit Test Command #

At the root of the project, run the following command:

 flutter  test test/dotenv_test.dart

Generate the env.dart file #

At the root of the example project, run the following command: like: /Users/danli/Desktop/2024/packages/package_flutter_env/example Because test is the development environment, the generated file is in the lib folder.

    flutter test test/env_test.dart
import 'package:abc_amap_poc/limo_page.dart';
import 'package:flutter/material.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:xbr_gaode_navi_amap/xbr_gaode_navi_amap.dart';

void main() {
  runApp(const MyApp());
}

Future<void> _requestLocationPermission() async {
  final status = await Permission.location.request();
  if (status.isDenied || status.isPermanentlyDenied) {
    await openAppSettings();
  }
}

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  @override
  void initState() {
    super.initState();
    _requestLocationPermission();
    XbrGaodeNaviAmap.initKey(
      androidKey: "",
      iosKey: "",
    );
    XbrGaodeNaviAmap.updatePrivacy(hasContains: true, hasShow: true, hasAgree: true);
  }

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: HomePage());
  }
}

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Limo Limo POC')),
      body: Center(
        child: ElevatedButton(
          child: const Text("Limo Limo Limo"),
          onPressed: () {
            Navigator.push(context, MaterialPageRoute(builder: (context) => const LimoPage()));
          },
        ),
      ),
    );
  }
}
import 'package:flutter/material.dart';
import 'package:scoped_model/scoped_model.dart';

import 'limo_model.dart';
import 'package:xbr_gaode_navi_amap/amap/amap_widget.dart';
import 'package:xbr_gaode_navi_amap/search/entity/input_tip_result.dart';

class LimoPage extends StatefulWidget {
  const LimoPage({super.key});

  @override
  State<LimoPage> createState() => _LimoPageState();
}

class _LimoPageState extends State<LimoPage> {
  late final LimoModel _model;

  final TextEditingController _startController = TextEditingController();
  final TextEditingController _endController = TextEditingController();
  final FocusNode _startFocusNode = FocusNode();
  final FocusNode _endFocusNode = FocusNode();
  final LayerLink _startLayerLink = LayerLink();
  final LayerLink _endLayerLink = LayerLink();

  OverlayEntry? _overlayEntry;

  @override
  void initState() {
    super.initState();
    _model = LimoModel();
    _model.onSuggestionsUpdated = _onSuggestionsUpdated;
    _model.onStartLabelUpdated = (label) {
      _startController.text = label;
    };
    _model.init();

    _startFocusNode.addListener(() {
      if (!_startFocusNode.hasFocus) {
        Future.delayed(const Duration(milliseconds: 150), _hideOverlay);
      }
    });
    _endFocusNode.addListener(() {
      if (!_endFocusNode.hasFocus) {
        Future.delayed(const Duration(milliseconds: 150), _hideOverlay);
      }
    });
  }

  @override
  void dispose() {
    _model.cleanup();
    _hideOverlay();
    _startController.dispose();
    _endController.dispose();
    _startFocusNode.dispose();
    _endFocusNode.dispose();
    super.dispose();
  }

  // ──────────────────── Overlay Management ────────────────────

  void _onSuggestionsUpdated() {
    if (_model.suggestions.isNotEmpty) {
      _showOverlay(_model.isStartActive ? _startLayerLink : _endLayerLink, showAbove: !_model.isStartActive);
    } else {
      _hideOverlay();
    }
  }

  void _showOverlay(LayerLink link, {bool showAbove = false}) {
    _overlayEntry?.remove();
    final suggestions = List<InputTipResult>.of(_model.suggestions);
    _overlayEntry = OverlayEntry(
      builder:
          (ctx) => CompositedTransformFollower(
            link: link,
            showWhenUnlinked: false,
            targetAnchor: showAbove ? Alignment.topLeft : Alignment.bottomLeft,
            followerAnchor: showAbove ? Alignment.bottomLeft : Alignment.topLeft,
            child: Align(
              alignment: showAbove ? Alignment.bottomLeft : Alignment.topLeft,
              child: Material(
                elevation: 6,
                borderRadius: BorderRadius.circular(8),
                child: ConstrainedBox(
                  constraints: BoxConstraints(maxWidth: MediaQuery.of(ctx).size.width - 32, maxHeight: 220),
                  child: ListView.separated(
                    padding: EdgeInsets.zero,
                    shrinkWrap: true,
                    itemCount: suggestions.length,
                    separatorBuilder: (_, __) => const Divider(height: 1),
                    itemBuilder: (_, i) {
                      final tip = suggestions[i];
                      return ListTile(
                        leading: const Icon(Icons.location_on_outlined, color: Colors.grey, size: 20),
                        title: Text(tip.name ?? '', style: const TextStyle(fontSize: 14)),
                        subtitle:
                            (tip.address?.isNotEmpty ?? false)
                                ? Text(
                                  tip.address!,
                                  style: const TextStyle(fontSize: 12, color: Colors.grey),
                                  maxLines: 1,
                                  overflow: TextOverflow.ellipsis,
                                )
                                : null,
                        onTap: () => _onSuggestionSelected(tip),
                        visualDensity: VisualDensity.compact,
                      );
                    },
                  ),
                ),
              ),
            ),
          ),
    );
    Overlay.of(context).insert(_overlayEntry!);
  }

  void _hideOverlay() {
    _overlayEntry?.remove();
    _overlayEntry = null;
  }

  void _onSuggestionSelected(InputTipResult tip) {
    _hideOverlay();

    if (_model.isStartActive) {
      _startController.text = tip.name ?? '';
      _startFocusNode.unfocus();
    } else {
      _endController.text = tip.name ?? '';
      _endFocusNode.unfocus();
    }

    _model.selectSuggestion(tip);
  }

  // ──────────────────── Build ────────────────────

  @override
  Widget build(BuildContext context) {
    return ScopedModel<LimoModel>(
      model: _model,
      child: ScopedModelDescendant<LimoModel>(
        builder: (context, child, model) {
          return Scaffold(
            appBar: AppBar(
              title: const Text('Limo Page'),
              backgroundColor: Colors.transparent,
              elevation: 0,
              foregroundColor: Colors.black87,
            ),
            extendBodyBehindAppBar: true,
            floatingActionButton:
                (model.cachedRoutePoints != null && !model.isNavigating)
                    ? FloatingActionButton.extended(
                      onPressed: model.startNavigation,
                      backgroundColor: const Color(0xFF2E7D32),
                      icon: const Icon(Icons.navigation, color: Colors.white),
                      label: const Text('Start', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)),
                    )
                    : null,
            floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
            body: Column(
              children: [
                // Top half: map
                Expanded(
                  // flex: 2,
                  child:
                      model.cameraPosition == null
                          ? const Center(
                            child: Column(
                              mainAxisSize: MainAxisSize.min,
                              children: [
                                CircularProgressIndicator(),
                                SizedBox(height: 12),
                                Text('Acquiring location…', style: TextStyle(color: Colors.grey)),
                              ],
                            ),
                          )
                          : Stack(
                            children: [
                              AmapWidget(
                                initCameraPosition: model.cameraPosition!,
                                uiController: model.uiController,
                                onMapCreated: model.onMapCreated,
                                onCameraMove: (_) {
                                  if (!model.isMovingProgrammatically) {
                                    model.cameraFollowsLocation = false;
                                  }
                                },
                              ),
                              Positioned(
                                right: 12,
                                bottom: 12,
                                child: Material(
                                  color: Colors.white,
                                  shape: const CircleBorder(),
                                  elevation: 4,
                                  child: InkWell(
                                    customBorder: const CircleBorder(),
                                    onTap: model.centerOnMyLocation,
                                    child: const Padding(
                                      padding: EdgeInsets.all(10),
                                      child: Icon(Icons.my_location, color: Color(0xFF1976D2), size: 24),
                                    ),
                                  ),
                                ),
                              ),
                            ],
                          ),
                ),
                // Bottom half: origin / destination input
                Expanded(
                  // flex: 3,
                  child: GestureDetector(
                    behavior: HitTestBehavior.translucent,
                    onTap: () {
                      _startFocusNode.unfocus();
                      _endFocusNode.unfocus();
                      _hideOverlay();
                    },
                    child: SingleChildScrollView(
                      padding: const EdgeInsets.all(16),
                      child: Column(
                        children: [
                          // ETA card
                          if (model.endLatLng != null)
                            AnimatedSwitcher(
                              duration: const Duration(milliseconds: 400),
                              child:
                                  (model.toEndDistanceM == null)
                                      ? const Padding(
                                        padding: EdgeInsets.only(bottom: 12),
                                        child: LinearProgressIndicator(),
                                      )
                                      : Container(
                                        key: const ValueKey('eta_card'),
                                        margin: const EdgeInsets.only(bottom: 12),
                                        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
                                        decoration: BoxDecoration(
                                          color: const Color(0xFF1976D2),
                                          borderRadius: BorderRadius.circular(10),
                                        ),
                                        child: Row(
                                          children: [
                                            const Icon(Icons.directions_car, color: Colors.white, size: 20),
                                            const SizedBox(width: 8),
                                            Text(
                                              'To destination: ${model.formatDistance(model.toEndDistanceM!)}',
                                              style: const TextStyle(
                                                color: Colors.white,
                                                fontSize: 14,
                                                fontWeight: FontWeight.w600,
                                              ),
                                            ),
                                            const SizedBox(width: 16),
                                            const Icon(Icons.access_time, color: Colors.white70, size: 16),
                                            const SizedBox(width: 4),
                                            Text(
                                              model.formatDuration(model.toEndDurationS!),
                                              style: const TextStyle(color: Colors.white70, fontSize: 14),
                                            ),
                                          ],
                                        ),
                                      ),
                            ),
                          CompositedTransformTarget(
                            link: _startLayerLink,
                            child: TextField(
                              controller: _startController,
                              focusNode: _startFocusNode,
                              onChanged: (text) => model.onTextChanged(text, true),
                              decoration: const InputDecoration(
                                labelText: 'Origin',
                                hintText: 'Enter pickup location',
                                prefixIcon: Icon(Icons.trip_origin, color: Colors.green),
                                border: OutlineInputBorder(),
                              ),
                            ),
                          ),
                          // Nearby pickups (horizontal chip list)
                          if (model.nearbyPickups.isNotEmpty)
                            Padding(
                              padding: const EdgeInsets.only(top: 8),
                              child: SizedBox(
                                height: 36,
                                child: ListView.separated(
                                  scrollDirection: Axis.horizontal,
                                  itemCount: model.nearbyPickups.length,
                                  separatorBuilder: (_, __) => const SizedBox(width: 8),
                                  itemBuilder: (_, i) {
                                    final poi = model.nearbyPickups[i];
                                    final isSelected = model.selectedPickupIndex == i;
                                    return GestureDetector(
                                      onTap: () {
                                        _startController.text = model.selectPickup(i);
                                        _startFocusNode.unfocus();
                                        _hideOverlay();
                                      },
                                      child: AnimatedContainer(
                                        duration: const Duration(milliseconds: 200),
                                        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
                                        decoration: BoxDecoration(
                                          color: isSelected ? const Color(0xFF1976D2) : Colors.grey.shade100,
                                          borderRadius: BorderRadius.circular(18),
                                          border: Border.all(
                                            color: isSelected ? const Color(0xFF1976D2) : Colors.grey.shade300,
                                          ),
                                        ),
                                        child: Row(
                                          mainAxisSize: MainAxisSize.min,
                                          children: [
                                            Icon(Icons.place, size: 14, color: isSelected ? Colors.white : Colors.grey),
                                            const SizedBox(width: 4),
                                            Text(
                                              poi.name ?? '',
                                              style: TextStyle(
                                                fontSize: 12,
                                                color: isSelected ? Colors.white : Colors.black87,
                                              ),
                                            ),
                                          ],
                                        ),
                                      ),
                                    );
                                  },
                                ),
                              ),
                            ),
                          const SizedBox(height: 12),
                          CompositedTransformTarget(
                            link: _endLayerLink,
                            child: TextField(
                              controller: _endController,
                              focusNode: _endFocusNode,
                              onChanged: (text) => model.onTextChanged(text, false),
                              decoration: const InputDecoration(
                                labelText: 'Destination',
                                hintText: 'Enter destination',
                                prefixIcon: Icon(Icons.location_on, color: Colors.red),
                                border: OutlineInputBorder(),
                              ),
                            ),
                          ),
                          // Recent destinations (horizontal chip list)
                          if (model.recentDestinations.isNotEmpty)
                            Padding(
                              padding: const EdgeInsets.only(top: 8),
                              child: SizedBox(
                                height: 36,
                                child: ListView.separated(
                                  scrollDirection: Axis.horizontal,
                                  itemCount: model.recentDestinations.length,
                                  separatorBuilder: (_, __) => const SizedBox(width: 8),
                                  itemBuilder: (_, i) {
                                    final dest = model.recentDestinations[i];
                                    final isSelected = model.endLatLng != null && dest.location == model.endLatLng;
                                    return GestureDetector(
                                      onTap: () {
                                        _endController.text = dest.name ?? '';
                                        _endFocusNode.unfocus();
                                        _hideOverlay();
                                        model.selectRecentDestination(dest);
                                      },
                                      child: AnimatedContainer(
                                        duration: const Duration(milliseconds: 200),
                                        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
                                        decoration: BoxDecoration(
                                          color:
                                              isSelected ? const Color.fromARGB(255, 2, 119, 2) : Colors.grey.shade100,
                                          borderRadius: BorderRadius.circular(18),
                                          border: Border.all(
                                            color:
                                                isSelected
                                                    ? const Color.fromARGB(255, 2, 119, 2)
                                                    : Colors.grey.shade300,
                                          ),
                                        ),
                                        child: Row(
                                          mainAxisSize: MainAxisSize.min,
                                          children: [
                                            Icon(
                                              Icons.history,
                                              size: 14,
                                              color: isSelected ? Colors.white : Colors.grey,
                                            ),
                                            const SizedBox(width: 4),
                                            Text(
                                              dest.name ?? '',
                                              style: TextStyle(
                                                fontSize: 12,
                                                color: isSelected ? Colors.white : Colors.black87,
                                              ),
                                            ),
                                          ],
                                        ),
                                      ),
                                    );
                                  },
                                ),
                              ),
                            ),
                        ],
                      ),
                    ),
                  ),
                ),
              ],
            ),
          );
        },
      ),
    );
  }
}
import 'dart:async';
import 'dart:convert';
import 'dart:ui' as ui;

import 'package:flutter/material.dart';
import 'package:scoped_model/scoped_model.dart';
import 'package:shared_preferences/shared_preferences.dart';

import 'package:xbr_gaode_navi_amap/amap/base/amap_flutter_base.dart';
import 'package:xbr_gaode_navi_amap/amap/core/xbr_ui_controller.dart';
import 'package:xbr_gaode_navi_amap/amap/map/amap_flutter_map.dart';
import 'package:xbr_gaode_navi_amap/amap/map/src/types/camera.dart';
import 'package:xbr_gaode_navi_amap/amap/map/src/types/bitmap.dart';
import 'package:xbr_gaode_navi_amap/amap/map/src/types/marker.dart';
import 'package:xbr_gaode_navi_amap/amap/map/src/types/polyline.dart';
import 'package:xbr_gaode_navi_amap/location/xbr_location_service.dart';
import 'package:xbr_gaode_navi_amap/_core/location_option.dart';
import 'package:xbr_gaode_navi_amap/search/entity/input_tip_result.dart';
import 'package:xbr_gaode_navi_amap/search/entity/poi_result.dart';
import 'package:xbr_gaode_navi_amap/search/utils/search_util.dart';
import 'package:xbr_gaode_navi_amap/search/xbr_search.dart';

class LimoModel extends Model {
  static const String myLocationKey = 'my_location';
  static const String startMarkerKey = 'start_location';
  static const String endMarkerKey = 'end_location';
  static const String routePolylineKey = 'driving_route';
  static const String traveledPolylineKey = 'traveled_route';
  static const String remainingPolylineKey = 'remaining_route';

  final AMapUIController uiController = AMapUIController();
  AMapController? mapController;

  // --- Location ---
  String? currentCity;
  LatLng? lastMyLocation;
  CameraPosition? cameraPosition;
  bool cameraFollowsLocation = true;
  bool isMovingProgrammatically = false;

  // --- Origin & Destination ---
  LatLng? startLatLng;
  LatLng? endLatLng;

  // --- Route Cache ---
  List<LatLng>? cachedRoutePoints;

  // --- ETA ---
  int? toEndDistanceM;
  int? toEndDurationS;
  DateTime? _lastEtaCalcTime;

  // --- Pulse Animation ---
  List<BitmapDescriptor>? pulseFrames;
  int pulseFrame = 0;
  Timer? _pulseTimer;

  // --- Nearby Pickups ---
  List<Pois> nearbyPickups = [];
  int? selectedPickupIndex;
  bool _nearbyPickupsFetched = false;
  int _prevPickupCount = 0;

  // --- Route Progress ---
  Timer? _routeUpdateTimer;
  bool isNavigating = false;

  // --- Recent Destinations ---
  final List<InputTipResult> recentDestinations = [];
  static const int _maxRecentDest = 5;
  static const String _prefsKey = 'limo_recent_destinations';

  // --- Search ---
  List<InputTipResult> suggestions = [];
  bool isStartActive = true;
  Timer? _debounce;

  /// Notifies the UI layer to show/hide the suggestion overlay.
  VoidCallback? onSuggestionsUpdated;

  /// Called when the origin label changes (drag end / pickup selected); UI updates the text field.
  Function(String label)? onStartLabelUpdated;

  bool _isDisposed = false;

  // ──────────────────── Lifecycle ────────────────────

  void init() {
    _loadRecentDestinations();
    _startLocating();
  }

  void cleanup() {
    _isDisposed = true;
    _debounce?.cancel();
    _pulseTimer?.cancel();
    _routeUpdateTimer?.cancel();
    XbrLocation.instance().destroyLocation(clientKey: 'limo_page_location');
  }

  // ──────────────────── Location ────────────────────

  void _startLocating() {
    XbrLocation.instance().startTimeLocation(
      clientKey: 'limo_page_location',
      interval: 2000,
      locationMode: AMapLocationMode.Hight_Accuracy,
      desiredAccuracy: DesiredAccuracy.BestForNavigation,
      callback: (info) {
        if (_isDisposed) return;
        final lat = info.latitude;
        final lng = info.longitude;
        if (lat == null || lng == null) return;

        currentCity = info.city;
        final pos = LatLng(lat, lng);
        lastMyLocation = pos;

        if (cameraFollowsLocation) {
          cameraPosition = CameraPosition(target: pos, zoom: 18);
          cameraFollowsLocation = false;
          // Fetch nearby pickups on first fix
          if (!_nearbyPickupsFetched) {
            _nearbyPickupsFetched = true;
            fetchNearbyPickups(pos);
          }
        }

        if (pulseFrames == null) {
          _initPulseAnimation(pos);
        } else {
          uiController.saveMarker(myLocationKey, buildMyLocationMarker(pos));
          uiController.refreshUI();
        }

        if (endLatLng != null && isNavigating) {
          updateEta(pos);
        }

        notifyListeners();
      },
    );
  }

  // ──────────────────── ETA ────────────────────

  void updateEta(LatLng from, {bool force = false}) {
    final end = endLatLng;
    if (end == null) return;
    final now = DateTime.now();
    if (!force && _lastEtaCalcTime != null && now.difference(_lastEtaCalcTime!).inSeconds < 10) return;
    _lastEtaCalcTime = now;
    SearchUtil.calculate(
      wayPoints: [from, end],
      calculateBack: (distanceM, durationS) {
        if (_isDisposed) return;
        toEndDistanceM = distanceM;
        toEndDurationS = durationS;
        notifyListeners();
      },
    );
  }

  String formatDistance(int meters) {
    if (meters >= 1000) {
      return '${(meters / 1000).toStringAsFixed(1)} km';
    }
    return '$meters m';
  }

  String formatDuration(int seconds) {
    final minutes = (seconds / 60).ceil();
    if (minutes < 1) return '< 1 min';
    return '~$minutes min';
  }

  // ──────────────────── My Location Marker ────────────────────

  Marker buildMyLocationMarker(LatLng pos) {
    return Marker(
      position: pos,
      icon:
          pulseFrames != null
              ? pulseFrames![pulseFrame]
              : BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueAzure),
      anchor: const Offset(0.5, 0.5),
      infoWindow: const InfoWindow(title: 'My Location'),
    );
  }

  Future<void> _initPulseAnimation(LatLng pos) async {
    pulseFrames = await Future.wait([_buildDotFrame(0), _buildDotFrame(1), _buildDotFrame(2)]);
    if (_isDisposed) return;
    uiController.saveMarker(myLocationKey, buildMyLocationMarker(pos));
    uiController.refreshUI();
    _pulseTimer = Timer.periodic(const Duration(milliseconds: 500), (_) {
      if (_isDisposed || lastMyLocation == null || pulseFrames == null) return;
      pulseFrame = (pulseFrame + 1) % pulseFrames!.length;
      uiController.saveMarker(myLocationKey, buildMyLocationMarker(lastMyLocation!));
      uiController.refreshUI();
    });
  }

  Future<BitmapDescriptor> _buildDotFrame(int frame) async {
    const double size = 64.0;
    const double cx = size / 2;
    const double cy = size / 2;
    final double ringRadius = [14.0, 20.0, 26.0][frame];
    final double ringOpacity = [0.55, 0.35, 0.15][frame];

    final recorder = ui.PictureRecorder();
    final canvas = Canvas(recorder, const Rect.fromLTWH(0, 0, size, size));

    canvas.drawCircle(const Offset(cx, cy), ringRadius, Paint()..color = Color.fromRGBO(30, 136, 229, ringOpacity));
    canvas.drawCircle(const Offset(cx, cy), 9.0, Paint()..color = const Color(0xFF1E88E5));
    canvas.drawCircle(
      const Offset(cx, cy),
      9.0,
      Paint()
        ..color = Colors.white
        ..style = PaintingStyle.stroke
        ..strokeWidth = 2.5,
    );
    canvas.drawCircle(const Offset(cx, cy), 3.5, Paint()..color = Colors.white);

    final picture = recorder.endRecording();
    final image = await picture.toImage(size.toInt(), size.toInt());
    final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
    return BitmapDescriptor.fromBytes(byteData!.buffer.asUint8List());
  }

  // ──────────────────── Nearby Pickups ────────────────────

  /// Builds a draggable origin marker (no label; used for drag / reverse-geocode scenarios).
  Marker _buildStartMarker(LatLng pos, {String? snippet}) {
    return Marker(
      position: pos,
      icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueGreen),
      infoWindow: InfoWindow(title: 'Origin', snippet: snippet),
      draggable: true,
      onDragEnd: onStartMarkerDragEnd,
    );
  }

  /// Renders a green origin marker bitmap with a name bubble (used when a pickup is selected).
  Future<Marker> _buildLabeledStartMarker(LatLng pos, String label, {String? snippet}) async {
    const double pinR = 14.0;
    const double pinH = 8.0;
    const double padding = 8.0;
    const double fontSize = 13.0;
    const double bitmapW = 180.0;

    final textPainter = TextPainter(
      text: TextSpan(
        text: label,
        style: const TextStyle(fontSize: fontSize, color: Colors.white, fontWeight: FontWeight.w600),
      ),
      textDirection: TextDirection.ltr,
    )..layout(maxWidth: bitmapW - padding * 2);

    final bubbleW = (textPainter.width + padding * 2).clamp(60.0, bitmapW);
    const bubbleH = fontSize + padding * 2;
    const totalH = bubbleH + pinH + pinR * 2 + 4;
    final totalW = bubbleW.clamp(pinR * 2, bitmapW);

    final recorder = ui.PictureRecorder();
    final canvas = Canvas(recorder, Rect.fromLTWH(0, 0, totalW, totalH));
    final cx = totalW / 2;

    // Bubble background
    final bubbleRect = RRect.fromLTRBR(cx - bubbleW / 2, 0, cx + bubbleW / 2, bubbleH, const Radius.circular(6));
    canvas.drawRRect(bubbleRect, Paint()..color = const Color(0xFF2E7D32));

    // Bubble text
    textPainter.paint(canvas, Offset(cx - textPainter.width / 2, padding * 0.8));

    // Bubble arrow tip
    final arrowPath =
        Path()
          ..moveTo(cx - 6, bubbleH)
          ..lineTo(cx + 6, bubbleH)
          ..lineTo(cx, bubbleH + pinH)
          ..close();
    canvas.drawPath(arrowPath, Paint()..color = const Color(0xFF2E7D32));

    // Green circle pin
    final pinY = bubbleH + pinH + pinR + 2;
    canvas.drawCircle(Offset(cx, pinY), pinR, Paint()..color = const Color(0xFF2E7D32));
    canvas.drawCircle(
      Offset(cx, pinY),
      pinR,
      Paint()
        ..color = Colors.white
        ..style = PaintingStyle.stroke
        ..strokeWidth = 2,
    );
    canvas.drawCircle(Offset(cx, pinY), 5.0, Paint()..color = Colors.white);

    final picture = recorder.endRecording();
    final image = await picture.toImage(totalW.toInt(), totalH.toInt());
    final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
    final icon = BitmapDescriptor.fromBytes(byteData!.buffer.asUint8List());

    final anchorY = (bubbleH + pinH + pinR + 2) / totalH;
    return Marker(
      position: pos,
      icon: icon,
      anchor: Offset(0.5, anchorY),
      infoWindow: InfoWindow(title: 'Origin', snippet: snippet),
      draggable: true,
      onDragEnd: onStartMarkerDragEnd,
    );
  }

  void fetchNearbyPickups(LatLng pos) {
    XbrSearch.boundSearch(
      point: pos,
      score: 800,
      page: 1,
      limit: 8,
      back: (code, result) {
        if (_isDisposed) return;
        // Remove previous batch of pickup markers
        for (int i = 0; i < _prevPickupCount; i++) {
          uiController.deleteMarker('pickup_$i');
        }
        nearbyPickups = (result.pois ?? []).where((p) => p.location != null).toList();
        _prevPickupCount = nearbyPickups.length;
        // Draw pickup markers on the map
        for (int i = 0; i < nearbyPickups.length; i++) {
          final poi = nearbyPickups[i];
          final loc = poi.enterLocation ?? poi.location!;
          final capturedIndex = i;
          uiController.saveMarker(
            'pickup_$i',
            Marker(
              position: loc,
              icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueCyan),
              // infoWindow: InfoWindow(title: poi.name ?? 'Pickup', snippet: poi.address),
              onTap: (_) {
                final name = selectPickup(capturedIndex);
                onStartLabelUpdated?.call(name);
              },
            ),
          );
        }
        uiController.refreshUI();
        notifyListeners();
      },
    );
  }

  /// Selects a nearby pickup point (chip or map marker); returns the POI name for the UI text field.
  String selectPickup(int index) {
    final poi = nearbyPickups[index];
    final loc = poi.enterLocation ?? poi.location!;
    final name = poi.name ?? '';

    selectedPickupIndex = index;
    startLatLng = loc;
    notifyListeners();

    // Async build labeled bubble marker
    _buildLabeledStartMarker(loc, name, snippet: poi.address).then((marker) {
      if (_isDisposed) return;
      uiController.saveMarker(startMarkerKey, marker);
      uiController.refreshUI();
    });

    if (endLatLng != null) drawRoute();

    return name;
  }

  /// Origin marker drag ended: snaps to nearest pickup within 100 m, otherwise stays and reverse-geocodes.
  void onStartMarkerDragEnd(String id, LatLng pos) {
    Pois? snapped;
    int? snappedIndex;
    double minDist = 100.0;
    for (int i = 0; i < nearbyPickups.length; i++) {
      final loc = nearbyPickups[i].enterLocation ?? nearbyPickups[i].location;
      if (loc == null) continue;
      final dist = AMapTools.distanceBetween(pos, loc);
      if (dist <= minDist) {
        minDist = dist;
        snapped = nearbyPickups[i];
        snappedIndex = i;
      }
    }
    if (snapped != null && snappedIndex != null) {
      final name = selectPickup(snappedIndex);
      onStartLabelUpdated?.call(name);
      return;
    }
    // No pickup snapped — update origin at drop position
    startLatLng = pos;
    selectedPickupIndex = null;
    uiController.saveMarker(startMarkerKey, _buildStartMarker(pos));
    uiController.refreshUI();
    notifyListeners();
    if (endLatLng != null) drawRoute();
    // Reverse-geocode to get a human-readable address label
    XbrSearch.reGeocoding(
      point: pos,
      back: (code, result) {
        if (_isDisposed) return;
        final addr = result.regeocodeAddress?.formattedAddress ?? '';
        onStartLabelUpdated?.call(addr.isNotEmpty ? addr : 'Selected location');
      },
    );
  }

  // ──────────────────── Search ────────────────────

  void onTextChanged(String text, bool isStart) {
    _debounce?.cancel();
    if (text.isEmpty) {
      suggestions = [];
      onSuggestionsUpdated?.call();
      return;
    }
    _debounce = Timer(const Duration(milliseconds: 300), () => _searchTips(text, isStart));
  }

  Future<void> _searchTips(String text, bool isStart) async {
    await XbrSearch.inputTips(
      newText: text,
      city: currentCity,
      cityLimit: currentCity != null,
      back: (code, results) {
        if (_isDisposed) return;
        suggestions = results;
        isStartActive = isStart;
        onSuggestionsUpdated?.call();
      },
    );
  }

  /// Selects a search suggestion; returns the selected location for the UI to update the text field.
  void selectSuggestion(InputTipResult tip) {
    final isStart = isStartActive;
    final loc = tip.location;
    if (loc == null) return;

    if (isStart) {
      startLatLng = loc;
    } else {
      endLatLng = loc;
      _addRecentDestination(tip);
    }

    final markerKey = isStart ? startMarkerKey : endMarkerKey;
    final Marker marker;
    if (isStart) {
      marker = _buildStartMarker(loc, snippet: tip.address);
    } else {
      marker = Marker(
        position: loc,
        icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed),
        infoWindow: InfoWindow(title: 'Destination', snippet: tip.address),
      );
    }

    uiController.saveMarker(markerKey, marker);
    notifyListeners();

    moveCamera(CameraUpdate.newCameraPosition(CameraPosition(target: loc, zoom: 16)));

    if (startLatLng != null && endLatLng != null) {
      drawRoute();
    }
  }

  void _addRecentDestination(InputTipResult tip) {
    recentDestinations.removeWhere((d) => d.name == tip.name || (d.location != null && d.location == tip.location));
    recentDestinations.insert(0, tip);
    if (recentDestinations.length > _maxRecentDest) {
      recentDestinations.removeLast();
    }
    _saveRecentDestinations();
  }

  Future<void> _saveRecentDestinations() async {
    final prefs = await SharedPreferences.getInstance();
    final jsonList = recentDestinations.map((d) => jsonEncode(d.toJson())).toList();
    await prefs.setStringList(_prefsKey, jsonList);
  }

  Future<void> _loadRecentDestinations() async {
    final prefs = await SharedPreferences.getInstance();
    final jsonList = prefs.getStringList(_prefsKey) ?? [];
    final loaded =
        jsonList
            .map((s) {
              try {
                return InputTipResult.fromJson(jsonDecode(s) as Map<String, dynamic>);
              } catch (_) {
                return null;
              }
            })
            .whereType<InputTipResult>()
            .toList();
    if (loaded.isNotEmpty) {
      recentDestinations.addAll(loaded);
      notifyListeners();
    }
  }

  /// Selects a destination from recent history.
  void selectRecentDestination(InputTipResult tip) {
    final loc = tip.location;
    if (loc == null) return;
    endLatLng = loc;
    _addRecentDestination(tip);

    uiController.saveMarker(
      endMarkerKey,
      Marker(
        position: loc,
        icon: BitmapDescriptor.defaultMarkerWithHue(BitmapDescriptor.hueRed),
        infoWindow: InfoWindow(title: 'Destination', snippet: tip.address),
      ),
    );
    notifyListeners();

    moveCamera(CameraUpdate.newCameraPosition(CameraPosition(target: loc, zoom: 16)));

    if (startLatLng != null) drawRoute();
  }

  // ──────────────────── Camera ────────────────────

  void moveCamera(CameraUpdate update, {int? duration}) {
    isMovingProgrammatically = true;
    if (duration != null) {
      mapController?.moveCamera(update, duration: duration);
    } else {
      mapController?.moveCamera(update);
    }
    Future.delayed(const Duration(milliseconds: 1500), () {
      isMovingProgrammatically = false;
    });
  }

  /// Animate the camera to the current GPS position.
  void centerOnMyLocation() {
    final pos = lastMyLocation;
    if (pos == null) return;
    moveCamera(CameraUpdate.newCameraPosition(CameraPosition(target: pos, zoom: 18)), duration: 400);
  }

  // ──────────────────── Route Planning ────────────────────

  /// Returns the index of the closest point in [cachedRoutePoints] to [pos].
  int _findClosestRouteIndex(LatLng pos) {
    final points = cachedRoutePoints!;
    int closestIndex = 0;
    double minDist = double.infinity;
    for (int i = 0; i < points.length; i++) {
      final dist = AMapTools.distanceBetween(pos, points[i]);
      if (dist < minDist) {
        minDist = dist;
        closestIndex = i;
      }
    }
    return closestIndex;
  }

  /// Updates route polyline colors: gray for traveled portion, green for remaining.
  void _updateRouteProgress() {
    final pos = lastMyLocation;
    final points = cachedRoutePoints;
    if (pos == null || points == null || points.isEmpty) return;

    final splitIndex = _findClosestRouteIndex(pos);

    // Traveled segment (gray)
    if (splitIndex >= 1) {
      final traveled = points.sublist(0, splitIndex + 1);
      uiController.savePolyline(
        traveledPolylineKey,
        Polyline(
          points: traveled,
          color: const Color(0xFF9E9E9E),
          width: 8,
          joinType: JoinType.round,
          capType: CapType.round,
        ),
      );
    } else {
      uiController.deletePolyline(traveledPolylineKey);
    }

    // Remaining segment (green) — prepend current position to ensure continuity
    final remaining = [pos, ...points.sublist(splitIndex)];
    uiController.savePolyline(
      remainingPolylineKey,
      Polyline(
        points: remaining,
        color: const Color(0xFF2E7D32),
        width: 8,
        joinType: JoinType.round,
        capType: CapType.round,
      ),
    );

    uiController.refreshUI();

    if (endLatLng != null) updateEta(pos, force: true);
  }

  void drawRoute() {
    final start = startLatLng!;
    final end = endLatLng!;
    // Reset navigation state when re-planning
    _routeUpdateTimer?.cancel();
    isNavigating = false;
    SearchUtil.planning(
      wayPoints: [start, end],
      planningBack: (code, points, bounds) {
        if (_isDisposed || code != 1000 || points.isEmpty) return;
        cachedRoutePoints = points;
        // Clear progress polylines, draw full blue route
        uiController.deletePolyline(traveledPolylineKey);
        uiController.deletePolyline(remainingPolylineKey);
        uiController.savePolyline(
          routePolylineKey,
          Polyline(
            points: points,
            color: const Color(0xFF1976D2),
            width: 8,
            joinType: JoinType.round,
            capType: CapType.round,
          ),
        );
        uiController.refreshUI();
        moveCamera(CameraUpdate.newLatLngBounds(bounds, 60), duration: 800);
        // ETA calculated from origin → destination
        updateEta(start, force: true);
        notifyListeners();
      },
    );
  }

  /// "Start" button pressed: switches to navigation mode and begins updating route progress and ETA from GPS.
  void startNavigation() {
    if (cachedRoutePoints == null || endLatLng == null) return;
    isNavigating = true;
    // Replace blue full-route line with gray/green progress lines
    uiController.deletePolyline(routePolylineKey);
    _updateRouteProgress();
    notifyListeners();
    _routeUpdateTimer?.cancel();
    _routeUpdateTimer = Timer.periodic(const Duration(seconds: 10), (_) {
      if (_isDisposed) return;
      _updateRouteProgress();
    });
  }

  /// Called when the map controller is ready.
  void onMapCreated(AMapController controller) {
    mapController = controller;
    if (cachedRoutePoints != null) {
      if (isNavigating) {
        _updateRouteProgress();
      } else {
        uiController.savePolyline(
          routePolylineKey,
          Polyline(
            points: cachedRoutePoints!,
            color: const Color(0xFF1976D2),
            width: 8,
            joinType: JoinType.round,
            capType: CapType.round,
          ),
        );
      }
    }
    uiController.refreshUI();
  }
}

1
likes
0
points
5
downloads

Publisher

unverified uploader

Weekly Downloads

Using MIAppBarWidget to create a custom app bar.

Homepage

License

unknown (license)

Dependencies

flutter, path

More

Packages that depend on package_flutter_env