Preview:
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:provider/provider.dart';

/// Data model for the user's mission and simulated location.
class MissionData extends ChangeNotifier {
  final double _totalDistanceKm;
  double _currentDistanceKm;
  final double _userLatitude;
  final double _userLongitude;

  /// Initializes the mission with a total distance and a simulated user location.
  ///
  /// The initial current distance walked is 0.0.
  MissionData({
    required double totalDistanceKm,
    required double userLatitude,
    required double userLongitude,
    double initialDistanceKm = 0.0,
  })  : _totalDistanceKm = totalDistanceKm,
        _userLatitude = userLatitude,
        _userLongitude = userLongitude,
        _currentDistanceKm = initialDistanceKm {
    if (_totalDistanceKm <= 0) {
      throw ArgumentError('Total distance must be positive.');
    }
    if (_currentDistanceKm < 0) {
      throw ArgumentError('Initial distance cannot be negative.');
    }
    if (_currentDistanceKm > _totalDistanceKm) {
      _currentDistanceKm = _totalDistanceKm; // Cap initial distance at total
    }
  }

  /// The total distance required for the mission in kilometers.
  double get totalDistanceKm => _totalDistanceKm;

  /// The current distance walked by the user in kilometers.
  double get currentDistanceKm => _currentDistanceKm;

  /// The simulated geographical latitude of the user.
  double get userLatitude => _userLatitude;

  /// The simulated geographical longitude of the user.
  double get userLongitude => _userLongitude;

  /// The remaining distance to complete the mission in kilometers.
  double get remainingDistanceKm =>
      (_totalDistanceKm - _currentDistanceKm).clamp(0.0, _totalDistanceKm);

  /// The progress of the mission as a percentage (0.0 to 1.0).
  double get progressPercentage =>
      _currentDistanceKm / _totalDistanceKm.clamp(1.0, double.infinity);

  /// Adds a specified [distance] in kilometers to the current distance walked.
  ///
  /// The distance added must be positive. The current distance will not exceed
  /// the total mission distance.
  void addDistance(double distance) {
    if (distance <= 0) {
      throw ArgumentError('Distance to add must be positive.');
    }
    _currentDistanceKm = (_currentDistanceKm + distance).clamp(0.0, _totalDistanceKm);
    notifyListeners();
  }
}

/// The main application widget for displaying location and mission.
class LocationMissionApp extends StatelessWidget {
  const LocationMissionApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<MissionData>(
      create: (context) => MissionData(
        totalDistanceKm: 100.0, // Mission: walk 100 kilometers
        userLatitude: 51.5, // Simulated London latitude
        userLongitude: -0.09, // Simulated London longitude
      ),
      builder: (context, child) {
        return MaterialApp(
          title: 'Mission Tracker',
          theme: ThemeData(
            primarySwatch: Colors.blue,
            visualDensity: VisualDensity.adaptivePlatformDensity,
          ),
          home: const MissionScreen(),
        );
      },
    );
  }
}

/// Displays the map with simulated user location and mission progress.
class MissionScreen extends StatefulWidget {
  const MissionScreen({super.key});

  @override
  State<MissionScreen> createState() => _MissionScreenState();
}

class _MissionScreenState extends State<MissionScreen> {
  final TextEditingController _distanceInputController = TextEditingController();
  final _formKey = GlobalKey<FormState>();

  @override
  void dispose() {
    _distanceInputController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Mission Progress'),
      ),
      body: Consumer<MissionData>(
        builder: (context, missionData, child) {
          return Column(
            children: <Widget>[
              Expanded(
                flex: 2,
                child: Card(
                  margin: const EdgeInsets.all(8.0),
                  clipBehavior: Clip.antiAlias,
                  child: Stack(
                    children: [
                      FlutterMap(
                        options: MapOptions(
                          initialCenter: LatLng(missionData.userLatitude, missionData.userLongitude),
                          initialZoom: 13.0,
                          interactionOptions: const InteractionOptions(
                            flags: InteractiveFlag.all & ~InteractiveFlag.rotate,
                          ),
                        ),
                        children: [
                          TileLayer(
                            urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
                            userAgentPackageName: 'com.example.app',
                          ),
                          MarkerLayer(
                            markers: [
                              Marker(
                                point: LatLng(missionData.userLatitude, missionData.userLongitude),
                                width: 80,
                                height: 80,
                                child: const Icon(
                                  Icons.location_on,
                                  color: Colors.red,
                                  size: 40.0,
                                ),
                              ),
                            ],
                          ),
                        ],
                      ),
                      Positioned(
                        top: 8,
                        left: 8,
                        child: Container(
                          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
                          decoration: BoxDecoration(
                            color: Colors.black54,
                            borderRadius: BorderRadius.circular(4),
                          ),
                          child: const Text(
                            'Simulated Location',
                            style: TextStyle(color: Colors.white, fontSize: 12),
                          ),
                        ),
                      ),
                    ],
                  ),
                ),
              ),
              Expanded(
                flex: 1,
                child: Padding(
                  padding: const EdgeInsets.all(16.0),
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.stretch,
                    children: <Widget>[
                      Text(
                        'Mission: Walk ${missionData.totalDistanceKm.toStringAsFixed(0)} km',
                        style: Theme.of(context).textTheme.headlineSmall,
                        textAlign: TextAlign.center,
                      ),
                      const SizedBox(height: 8.0),
                      Text(
                        'Progress: ${missionData.currentDistanceKm.toStringAsFixed(1)} km of ${missionData.totalDistanceKm.toStringAsFixed(0)} km',
                        style: Theme.of(context).textTheme.titleMedium,
                        textAlign: TextAlign.center,
                      ),
                      const SizedBox(height: 4.0),
                      LinearProgressIndicator(
                        value: missionData.progressPercentage,
                        minHeight: 10,
                        backgroundColor: Colors.grey[300],
                        valueColor: const AlwaysStoppedAnimation<Color>(Colors.green),
                      ),
                      const SizedBox(height: 8.0),
                      Text(
                        'Remaining: ${missionData.remainingDistanceKm.toStringAsFixed(1)} km',
                        style: Theme.of(context).textTheme.titleSmall,
                        textAlign: TextAlign.center,
                      ),
                      const Spacer(),
                      Form(
                        key: _formKey,
                        child: Row(
                          children: [
                            Expanded(
                              child: TextFormField(
                                controller: _distanceInputController,
                                keyboardType:
                                    const TextInputType.numberWithOptions(decimal: true),
                                decoration: InputDecoration(
                                  labelText: 'Add distance (km)',
                                  border: const OutlineInputBorder(),
                                  suffixText: 'km',
                                  errorStyle: TextStyle(
                                      color: Theme.of(context).colorScheme.error,
                                      fontSize: 10),
                                ),
                                validator: (String? value) {
                                  if (value == null || value.isEmpty) {
                                    return 'Please enter a distance';
                                  }
                                  final double? distance = double.tryParse(value);
                                  if (distance == null || distance <= 0) {
                                    return 'Enter a positive number';
                                  }
                                  return null;
                                },
                              ),
                            ),
                            const SizedBox(width: 8.0),
                            ElevatedButton(
                              onPressed: () {
                                if (_formKey.currentState!.validate()) {
                                  final double distanceToAdd =
                                      double.parse(_distanceInputController.text);
                                  missionData.addDistance(distanceToAdd);
                                  _distanceInputController.clear();
                                  FocusScope.of(context).unfocus(); // Dismiss keyboard
                                }
                              },
                              style: ElevatedButton.styleFrom(
                                padding: const EdgeInsets.symmetric(
                                    horizontal: 24, vertical: 16),
                              ),
                              child: const Text('Add Walk'),
                            ),
                          ],
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          );
        },
      ),
    );
  }
}

void main() {
  runApp(const LocationMissionApp());
}
downloadDownload PNG downloadDownload JPEG downloadDownload SVG

Tip: You can change the style, width & colours of the snippet with the inspect tool before clicking Download!

Click to optimize width for Twitter