Flutter共享单车位置查询:智能定位附近车辆

项目简介

共享单车位置查询是一款专为骑行用户打造的Flutter应用,帮助用户快速找到附近的共享单车,实时查看车辆状态和骑行费用。通过智能筛选和城市切换功能,让出行变得更加便捷高效。
运行效果图
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

核心功能

  • 城市选择:支持8个主要城市切换
  • 车辆查询:显示附近共享单车列表
  • 实时状态:查看车辆可用/使用中/维护中状态
  • 品牌筛选:美团、哈啰、青桔三大品牌
  • 类型筛选:单车、电单车、助力车分类
  • 距离筛选:自定义最大搜索距离
  • 电量显示:电动车辆实时电量查看
  • 费用查询:详细的计费规则说明
  • 费用预估:不同时长的费用计算
  • 数据刷新:实时更新车辆状态
  • 详情查看:单车详细信息页面

技术特点

  • Material Design 3设计风格
  • 多城市数据模拟
  • 智能筛选排序
  • 费用自动计算
  • 响应式卡片布局
  • ModalBottomSheet交互
  • 实时状态更新
  • 模拟数据生成

核心代码实现

1. 城市数据模型

class City {
  String name;              // 城市名称
  String code;              // 城市代码
  double latitude;          // 纬度
  double longitude;         // 经度

  City({
    required this.name,
    required this.code,
    required this.latitude,
    required this.longitude,
  });

  Map<String, dynamic> toJson() => {
        'name': name,
        'code': code,
        'latitude': latitude,
        'longitude': longitude,
      };

  factory City.fromJson(Map<String, dynamic> json) => City(
        name: json['name'],
        code: json['code'],
        latitude: (json['latitude'] as num).toDouble(),
        longitude: (json['longitude'] as num).toDouble(),
      );
}

模型字段说明

字段 类型 说明
name String 城市名称
code String 城市代码(如BJ、SH)
latitude double 纬度坐标
longitude double 经度坐标

支持城市列表

  • 北京(BJ):39.9042, 116.4074
  • 上海(SH):31.2304, 121.4737
  • 广州(GZ):23.1291, 113.2644
  • 深圳(SZ):22.5431, 114.0579
  • 杭州(HZ):30.2741, 120.1551
  • 成都(CD):30.5728, 104.0668
  • 武汉(WH):30.5928, 114.3055
  • 西安(XA):34.3416, 108.9398

2. 共享单车数据模型

class SharedBike {
  String id;                // 车辆编号
  String brand;             // 品牌
  String type;              // 类型
  double latitude;          // 纬度
  double longitude;         // 经度
  double battery;           // 电量
  String status;            // 状态
  double distance;          // 距离
  String address;           // 地址

  // 品牌颜色
  Color get brandColor {
    switch (brand) {
      case '美团单车':
        return Colors.yellow[700]!;
      case '哈啰单车':
        return Colors.blue;
      case '青桔单车':
        return Colors.green;
      default:
        return Colors.grey;
    }
  }

  // 类型图标
  IconData get typeIcon {
    switch (type) {
      case '单车':
        return Icons.pedal_bike;
      case '电单车':
        return Icons.electric_bike;
      case '助力车':
        return Icons.electric_scooter;
      default:
        return Icons.directions_bike;
    }
  }

  // 状态文本
  String get statusText {
    switch (status) {
      case 'available':
        return '可用';
      case 'in_use':
        return '使用中';
      case 'maintenance':
        return '维护中';
      default:
        return '未知';
    }
  }

  // 状态颜色
  Color get statusColor {
    switch (status) {
      case 'available':
        return Colors.green;
      case 'in_use':
        return Colors.orange;
      case 'maintenance':
        return Colors.red;
      default:
        return Colors.grey;
    }
  }
}

模型字段说明

字段 类型 说明
id String 唯一标识符
brand String 品牌名称
type String 车辆类型
latitude double 纬度坐标
longitude double 经度坐标
battery double 电量百分比
status String 车辆状态
distance double 距离(km)
address String 详细地址

计算属性

  • brandColor:根据品牌返回对应颜色
  • typeIcon:根据类型返回对应图标
  • statusText:返回状态中文描述
  • statusColor:返回状态对应颜色

品牌与颜色映射

  • 美团单车:黄色(Colors.yellow[700])
  • 哈啰单车:蓝色(Colors.blue)
  • 青桔单车:绿色(Colors.green)

车辆类型

  • 单车:普通自行车,无电量显示
  • 电单车:电动自行车,显示电量
  • 助力车:电动助力车,显示电量

车辆状态

  • available:可用(绿色)
  • in_use:使用中(橙色)
  • maintenance:维护中(红色)

3. 骑行费用数据模型

class RidingPrice {
  String brand;             // 品牌
  String type;              // 类型
  double startPrice;        // 起步价
  int freeMinutes;          // 免费时长
  double pricePerMinute;    // 每分钟价格
  double pricePerHour;      // 每小时价格
  String description;       // 描述

  // 计算费用
  double calculateCost(int minutes) {
    if (minutes <= freeMinutes) {
      return startPrice;
    }
    final extraMinutes = minutes - freeMinutes;
    return startPrice + (extraMinutes * pricePerMinute);
  }
}

模型字段说明

字段 类型 说明
brand String 品牌名称
type String 车辆类型
startPrice double 起步价(元)
freeMinutes int 免费时长(分钟)
pricePerMinute double 超时单价(元/分钟)
pricePerHour double 小时单价(元/小时)
description String 计费说明

费用计算方法

double calculateCost(int minutes) {
  if (minutes <= freeMinutes) {
    return startPrice;
  }
  final extraMinutes = minutes - freeMinutes;
  return startPrice + (extraMinutes * pricePerMinute);
}

计费规则示例

品牌 类型 起步价 免费时长 超时单价 小时单价
美团单车 单车 ¥1.5 15分钟 ¥0.5/分钟 ¥3.0/小时
美团单车 电单车 ¥2.0 20分钟 ¥1.0/分钟 ¥5.0/小时
哈啰单车 单车 ¥1.5 15分钟 ¥0.5/分钟 ¥3.0/小时
哈啰单车 电单车 ¥2.5 20分钟 ¥1.0/分钟 ¥5.0/小时
青桔单车 单车 ¥1.5 15分钟 ¥0.5/分钟 ¥3.0/小时
青桔单车 电单车 ¥2.0 20分钟 ¥1.0/分钟 ¥5.0/小时

4. 车辆数据生成

void _generateBikes() {
  if (_selectedCity == null) return;

  final random = Random();
  final brands = ['美团单车', '哈啰单车', '青桔单车'];
  final types = ['单车', '电单车', '助力车'];
  final statuses = ['available', 'in_use', 'maintenance'];

  _bikes = List.generate(50, (index) {
    final brand = brands[random.nextInt(brands.length)];
    final type = types[random.nextInt(types.length)];
    // 70%概率为可用状态
    final status = statuses[
        random.nextInt(10) < 7 ? 0 : random.nextInt(statuses.length)];

    // 在城市中心附近随机生成坐标
    final lat = _selectedCity!.latitude + (random.nextDouble() - 0.5) * 0.05;
    final lng = _selectedCity!.longitude + (random.nextDouble() - 0.5) * 0.05;
    final distance = random.nextDouble() * 3;

    return SharedBike(
      id: '${brand[0]}${type[0]}${(index + 1).toString().padLeft(4, '0')}',
      brand: brand,
      type: type,
      latitude: lat,
      longitude: lng,
      battery: type == '单车' ? 100 : (50 + random.nextDouble() * 50),
      status: status,
      distance: distance,
      address: '${_selectedCity!.name}市某某区某某路${index + 1}号附近',
    );
  });

  _applyFilters();
}

数据生成逻辑

  1. 每个城市生成50辆共享单车
  2. 品牌随机分配(美团、哈啰、青桔)
  3. 类型随机分配(单车、电单车、助力车)
  4. 70%概率为可用状态
  5. 坐标在城市中心±0.05度范围内
  6. 距离在0-3公里范围内
  7. 单车电量固定100%,电动车50-100%
  8. 车辆编号格式:品牌首字母+类型首字母+4位数字

5. 智能筛选功能

void _applyFilters() {
  setState(() {
    _filteredBikes = _bikes.where((bike) {
      // 仅显示可用车辆
      if (_onlyAvailable && bike.status != 'available') {
        return false;
      }
      // 品牌筛选
      if (_filterBrand != '全部' && bike.brand != _filterBrand) {
        return false;
      }
      // 类型筛选
      if (_filterType != '全部' && bike.type != _filterType) {
        return false;
      }
      // 距离筛选
      if (bike.distance > _maxDistance) {
        return false;
      }
      return true;
    }).toList();

    // 按距离排序
    _filteredBikes.sort((a, b) => a.distance.compareTo(b.distance));
  });
}

筛选条件

筛选项 选项 说明
品牌 全部/美团/哈啰/青桔 按品牌筛选
类型 全部/单车/电单车/助力车 按类型筛选
距离 0.5-5.0km 滑动条选择
可用性 开关 仅显示可用车辆

筛选流程

  1. 检查可用性开关
  2. 检查品牌匹配
  3. 检查类型匹配
  4. 检查距离范围
  5. 按距离升序排序

6. 城市选择对话框

void _selectCity() {
  showModalBottomSheet(
    context: context,
    builder: (context) => Container(
      padding: const EdgeInsets.all(16),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text(
            '选择城市',
            style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
          ),
          const SizedBox(height: 16),
          Wrap(
            spacing: 12,
            runSpacing: 12,
            children: _cities.map((city) {
              return ChoiceChip(
                label: Text(city.name),
                selected: _selectedCity?.code == city.code,
                onSelected: (selected) {
                  setState(() {
                    _selectedCity = city;
                    _generateBikes();
                  });
                  Navigator.pop(context);
                },
              );
            }).toList(),
          ),
        ],
      ),
    ),
  );
}

交互设计

  • 使用ModalBottomSheet展示城市列表
  • ChoiceChip实现单选效果
  • 选中城市后自动关闭对话框
  • 重新生成该城市的车辆数据

7. 筛选对话框

void _showFilterDialog() {
  showModalBottomSheet(
    context: context,
    builder: (context) => StatefulBuilder(
      builder: (context, setModalState) => Container(
        padding: const EdgeInsets.all(16),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('筛选条件', style: TextStyle(fontSize: 18)),
            // 品牌筛选
            Wrap(
              spacing: 8,
              children: ['全部', '美团单车', '哈啰单车', '青桔单车'].map((brand) {
                return ChoiceChip(
                  label: Text(brand),
                  selected: _filterBrand == brand,
                  onSelected: (selected) {
                    setModalState(() => _filterBrand = brand);
                    setState(() => _filterBrand = brand);
                    _applyFilters();
                  },
                );
              }).toList(),
            ),
            // 类型筛选
            Wrap(
              spacing: 8,
              children: ['全部', '单车', '电单车', '助力车'].map((type) {
                return ChoiceChip(
                  label: Text(type),
                  selected: _filterType == type,
                  onSelected: (selected) {
                    setModalState(() => _filterType = type);
                    setState(() => _filterType = type);
                    _applyFilters();
                  },
                );
              }).toList(),
            ),
            // 距离滑块
            Slider(
              value: _maxDistance,
              min: 0.5,
              max: 5.0,
              divisions: 9,
              label: '${_maxDistance.toStringAsFixed(1)}km',
              onChanged: (value) {
                setModalState(() => _maxDistance = value);
                setState(() => _maxDistance = value);
                _applyFilters();
              },
            ),
            // 可用性开关
            SwitchListTile(
              title: const Text('仅显示可用车辆'),
              value: _onlyAvailable,
              onChanged: (value) {
                setModalState(() => _onlyAvailable = value);
                setState(() => _onlyAvailable = value);
                _applyFilters();
              },
            ),
          ],
        ),
      ),
    ),
  );
}

StatefulBuilder的使用

  • 在ModalBottomSheet中实现局部状态更新
  • setModalState更新对话框内部UI
  • setState更新主页面数据
  • 实时应用筛选条件

8. 车辆卡片UI

Widget _buildBikeCard(SharedBike bike) {
  final price = _prices.firstWhere(
    (p) => p.brand == bike.brand && p.type == bike.type,
    orElse: () => _prices.first,
  );

  return Card(
    margin: const EdgeInsets.only(bottom: 16),
    child: InkWell(
      onTap: () {
        Navigator.push(
          context,
          MaterialPageRoute(
            builder: (_) => BikeDetailPage(bike: bike, price: price),
          ),
        );
      },
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            // 车辆基本信息
            Row(
              children: [
                Container(
                  padding: const EdgeInsets.all(12),
                  decoration: BoxDecoration(
                    color: bike.brandColor.withValues(alpha: 0.1),
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: Icon(
                    bike.typeIcon,
                    color: bike.brandColor,
                    size: 32,
                  ),
                ),
                const SizedBox(width: 12),
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Row(
                        children: [
                          Text(
                            bike.brand,
                            style: TextStyle(
                              fontSize: 16,
                              fontWeight: FontWeight.bold,
                              color: bike.brandColor,
                            ),
                          ),
                          const SizedBox(width: 8),
                          Container(
                            padding: const EdgeInsets.symmetric(
                              horizontal: 8,
                              vertical: 2,
                            ),
                            decoration: BoxDecoration(
                              color: Colors.grey[200],
                              borderRadius: BorderRadius.circular(8),
                            ),
                            child: Text(
                              bike.type,
                              style: const TextStyle(fontSize: 12),
                            ),
                          ),
                        ],
                      ),
                      const SizedBox(height: 4),
                      Text(
                        bike.id,
                        style: const TextStyle(color: Colors.grey),
                      ),
                    ],
                  ),
                ),
                Container(
                  padding: const EdgeInsets.symmetric(
                    horizontal: 12,
                    vertical: 6,
                  ),
                  decoration: BoxDecoration(
                    color: bike.statusColor,
                    borderRadius: BorderRadius.circular(12),
                  ),
                  child: Text(
                    bike.statusText,
                    style: const TextStyle(
                      color: Colors.white,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 12),
            // 距离和时间信息
            Row(
              children: [
                const Icon(Icons.navigation, size: 16, color: Colors.blue),
                const SizedBox(width: 4),
                Text(
                  '${(bike.distance * 1000).toStringAsFixed(0)}米',
                  style: const TextStyle(color: Colors.blue),
                ),
                const SizedBox(width: 16),
                const Icon(Icons.access_time, size: 16, color: Colors.grey),
                const SizedBox(width: 4),
                Text(
                  '步行约${(bike.distance * 12).toStringAsFixed(0)}分钟',
                  style: const TextStyle(color: Colors.grey),
                ),
              ],
            ),
            const SizedBox(height: 8),
            // 地址信息
            Row(
              children: [
                const Icon(Icons.location_on, size: 16, color: Colors.grey),
                const SizedBox(width: 4),
                Expanded(
                  child: Text(
                    bike.address,
                    style: const TextStyle(color: Colors.grey, fontSize: 12),
                  ),
                ),
              ],
            ),
            // 电量信息(仅电动车)
            if (bike.type != '单车') ...[
              const SizedBox(height: 12),
              Row(
                children: [
                  const Icon(Icons.battery_charging_full,
                      size: 16, color: Colors.green),
                  const SizedBox(width: 4),
                  Expanded(
                    child: LinearProgressIndicator(
                      value: bike.battery / 100,
                      backgroundColor: Colors.grey[200],
                      color: bike.battery > 50 ? Colors.green : Colors.orange,
                    ),
                  ),
                  const SizedBox(width: 8),
                  Text(
                    '${bike.battery.toStringAsFixed(0)}%',
                    style: TextStyle(
                      color: bike.battery > 50 ? Colors.green : Colors.orange,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ],
              ),
            ],
            const SizedBox(height: 12),
            // 费用信息
            Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: Colors.blue[50],
                borderRadius: BorderRadius.circular(8),
              ),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Text(
                    '起步价 ¥${price.startPrice.toStringAsFixed(1)}',
                    style: const TextStyle(fontWeight: FontWeight.bold),
                  ),
                  Text(
                    '前${price.freeMinutes}分钟',
                    style: const TextStyle(color: Colors.grey),
                  ),
                  Text(
                    ${price.pricePerMinute.toStringAsFixed(1)}/分钟',
                    style: const TextStyle(color: Colors.red),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    ),
  );
}

卡片布局结构

  1. 顶部:品牌图标、名称、类型、状态标签
  2. 中部:距离、步行时间、地址信息
  3. 电量:电动车显示电量进度条
  4. 底部:费用信息卡片

视觉设计要点

  • 品牌颜色作为主题色
  • 状态标签使用对应颜色
  • 电量进度条颜色根据电量变化
  • 费用信息使用浅蓝色背景突出显示

9. 汇总卡片

Widget _buildSummaryCard(int availableCount) {
  return Card(
    margin: const EdgeInsets.all(16),
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceAround,
        children: [
          _buildSummaryItem(
            Icons.pedal_bike,
            '附近车辆',
            '${_filteredBikes.length}辆',
          ),
          _buildSummaryItem(
            Icons.check_circle,
            '可用',
            '$availableCount辆',
            color: Colors.green,
          ),
          _buildSummaryItem(
            Icons.navigation,
            '最近',
            _filteredBikes.isEmpty
                ? '-'
                : '${_filteredBikes.first.distance.toStringAsFixed(0)}m',
            color: Colors.blue,
          ),
        ],
      ),
    ),
  );
}

Widget _buildSummaryItem(IconData icon, String label, String value,
    {Color? color}) {
  return Column(
    children: [
      Icon(icon, size: 32, color: color ?? Colors.grey),
      const SizedBox(height: 4),
      Text(label, style: const TextStyle(color: Colors.grey, fontSize: 12)),
      const SizedBox(height: 4),
      Text(
        value,
        style: TextStyle(
          fontSize: 16,
          fontWeight: FontWeight.bold,
          color: color,
        ),
      ),
    ],
  );
}

汇总信息

  • 附近车辆:筛选后的总数量
  • 可用车辆:状态为available的数量
  • 最近距离:第一辆车的距离(已排序)

10. 单车详情页

class BikeDetailPage extends StatelessWidget {
  final SharedBike bike;
  final RidingPrice price;

  const BikeDetailPage({
    super.key,
    required this.bike,
    required this.price,
  });

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('${bike.brand} - ${bike.id}'),
      ),
      body: SingleChildScrollView(
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            _buildBikeInfo(),
            _buildLocationInfo(),
            _buildPriceInfo(),
            _buildCostCalculator(),
          ],
        ),
      ),
      bottomNavigationBar: SafeArea(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: FilledButton.icon(
            onPressed: bike.status == 'available'
                ? () {
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(content: Text('开始骑行')),
                    );
                  }
                : null,
            icon: const Icon(Icons.pedal_bike),
            label: Text(bike.status == 'available' ? '开始骑行' : bike.statusText),
            style: FilledButton.styleFrom(
              padding: const EdgeInsets.symmetric(vertical: 16),
            ),
          ),
        ),
      ),
    );
  }
}

详情页结构

  1. 车辆信息卡片:品牌、类型、编号、状态、电量
  2. 位置信息卡片:距离、步行时间、详细地址
  3. 费用信息卡片:起步价、免费时长、计费规则
  4. 费用预估卡片:15/30/60分钟费用示例
  5. 底部按钮:开始骑行(仅可用状态)

11. 费用列表页

class PriceListPage extends StatelessWidget {
  final List<RidingPrice> prices;

  const PriceListPage({super.key, required this.prices});

  
  Widget build(BuildContext context) {
    final groupedPrices = <String, List<RidingPrice>>{};
    for (var price in prices) {
      if (!groupedPrices.containsKey(price.brand)) {
        groupedPrices[price.brand] = [];
      }
      groupedPrices[price.brand]!.add(price);
    }

    return Scaffold(
      appBar: AppBar(
        title: const Text('骑行费用'),
      ),
      body: ListView.builder(
        padding: const EdgeInsets.all(16),
        itemCount: groupedPrices.length,
        itemBuilder: (context, index) {
          final brand = groupedPrices.keys.elementAt(index);
          final brandPrices = groupedPrices[brand]!;
          return _buildBrandCard(brand, brandPrices);
        },
      ),
    );
  }
}

费用页面设计

  • 按品牌分组显示
  • 每个品牌显示所有车型费用
  • 包含起步价、免费时长、超时计费
  • 显示计费说明文字

12. 数据刷新功能

void _refreshBikes() {
  setState(() {
    final random = Random();
    for (var bike in _bikes) {
      // 30%概率更新状态
      if (random.nextDouble() < 0.3) {
        final statuses = ['available', 'in_use', 'maintenance'];
        bike.status = statuses[
            random.nextInt(10) < 7 ? 0 : random.nextInt(statuses.length)];
      }
      // 20%概率更新电量
      if (bike.type != '单车' && random.nextDouble() < 0.2) {
        bike.battery =
            (bike.battery + random.nextDouble() * 10 - 5).clamp(0, 100);
      }
    }
  });
  _applyFilters();
  ScaffoldMessenger.of(context).showSnackBar(
    const SnackBar(content: Text('数据已刷新')),
  );
}

刷新逻辑

  • 30%概率更新车辆状态
  • 20%概率更新电动车电量
  • 电量变化±5%,范围0-100%
  • 刷新后重新应用筛选条件
  • 显示刷新成功提示

技术要点详解

1. ModalBottomSheet的使用

ModalBottomSheet是Flutter中常用的底部弹出对话框组件,适合展示选项列表和筛选条件。

基本用法

showModalBottomSheet(
  context: context,
  builder: (context) => Container(
    padding: const EdgeInsets.all(16),
    child: Column(
      mainAxisSize: MainAxisSize.min,
      children: [
        // 内容
      ],
    ),
  ),
);

关键属性

  • context:上下文对象
  • builder:构建器函数
  • isScrollControlled:是否可滚动
  • shape:形状定义
  • backgroundColor:背景颜色

使用场景

  • 城市选择
  • 筛选条件设置
  • 快速操作菜单

2. ChoiceChip的使用

ChoiceChip是Material Design中的选择芯片组件,适合单选或多选场景。

基本用法

ChoiceChip(
  label: Text('选项'),
  selected: _selectedValue == '选项',
  onSelected: (selected) {
    setState(() {
      _selectedValue = '选项';
    });
  },
)

关键属性

  • label:标签文本
  • selected:是否选中
  • onSelected:选择回调
  • selectedColor:选中颜色
  • backgroundColor:背景颜色

使用场景

  • 城市选择
  • 品牌筛选
  • 类型筛选

3. Slider滑块组件

Slider用于在范围内选择数值,适合距离、价格等连续值的选择。

基本用法

Slider(
  value: _currentValue,
  min: 0.5,
  max: 5.0,
  divisions: 9,
  label: '${_currentValue.toStringAsFixed(1)}km',
  onChanged: (value) {
    setState(() {
      _currentValue = value;
    });
  },
)

关键属性

  • value:当前值
  • min:最小值
  • max:最大值
  • divisions:分段数
  • label:标签文本
  • onChanged:值变化回调

使用场景

  • 距离筛选
  • 价格范围
  • 时间选择

4. LinearProgressIndicator进度条

LinearProgressIndicator用于显示线性进度,适合电量、进度等百分比显示。

基本用法

LinearProgressIndicator(
  value: 0.75,
  backgroundColor: Colors.grey[200],
  color: Colors.green,
  minHeight: 8,
)

关键属性

  • value:进度值(0-1)
  • backgroundColor:背景颜色
  • color:进度颜色
  • minHeight:最小高度

动态颜色

color: bike.battery > 50 ? Colors.green : Colors.orange,

使用场景

  • 电量显示
  • 下载进度
  • 任务完成度

5. 计算属性的使用

计算属性(getter)可以根据对象状态动态返回值,避免存储冗余数据。

示例

class SharedBike {
  String status;
  
  String get statusText {
    switch (status) {
      case 'available':
        return '可用';
      case 'in_use':
        return '使用中';
      case 'maintenance':
        return '维护中';
      default:
        return '未知';
    }
  }
  
  Color get statusColor {
    switch (status) {
      case 'available':
        return Colors.green;
      case 'in_use':
        return Colors.orange;
      case 'maintenance':
        return Colors.red;
      default:
        return Colors.grey;
    }
  }
}

优势

  • 减少数据冗余
  • 保持数据一致性
  • 简化代码逻辑
  • 便于维护

6. 列表筛选与排序

使用Dart的集合操作实现高效的数据筛选和排序。

筛选

_filteredBikes = _bikes.where((bike) {
  if (_onlyAvailable && bike.status != 'available') {
    return false;
  }
  if (_filterBrand != '全部' && bike.brand != _filterBrand) {
    return false;
  }
  if (_filterType != '全部' && bike.type != _filterType) {
    return false;
  }
  if (bike.distance > _maxDistance) {
    return false;
  }
  return true;
}).toList();

排序

_filteredBikes.sort((a, b) => a.distance.compareTo(b.distance));

性能优化

  • 使用where进行链式筛选
  • 避免多次遍历列表
  • 在setState中更新状态

7. 费用计算算法

实现灵活的费用计算逻辑,支持起步价和超时计费。

计算公式

总费用 = { 起步价 if  t ≤ t free 起步价 + ( t − t free ) × p if  t > t free \text{总费用} = \begin{cases} \text{起步价} & \text{if } t \leq t_{\text{free}} \\ \text{起步价} + (t - t_{\text{free}}) \times p & \text{if } t > t_{\text{free}} \end{cases} 总费用={起步价起步价+(ttfree)×pif ttfreeif t>tfree

其中:

  • t t t 为骑行时长(分钟)
  • t free t_{\text{free}} tfree 为免费时长(分钟)
  • p p p 为超时单价(元/分钟)

代码实现

double calculateCost(int minutes) {
  if (minutes <= freeMinutes) {
    return startPrice;
  }
  final extraMinutes = minutes - freeMinutes;
  return startPrice + (extraMinutes * pricePerMinute);
}

示例计算

  • 骑行15分钟:¥1.5(起步价)
  • 骑行30分钟:¥1.5 + (30-15) × 0.5 = ¥9.0
  • 骑行60分钟:¥1.5 + (60-15) × 0.5 = ¥24.0

8. 距离计算

根据经纬度坐标计算两点之间的距离。

简化计算(适用于短距离):

double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
  final dLat = (lat2 - lat1) * 111.0; // 纬度1度约111km
  final dLon = (lon2 - lon1) * 111.0 * cos(lat1 * pi / 180);
  return sqrt(dLat * dLat + dLon * dLon);
}

Haversine公式(精确计算):

double haversineDistance(double lat1, double lon1, double lat2, double lon2) {
  const R = 6371; // 地球半径(km)
  final dLat = (lat2 - lat1) * pi / 180;
  final dLon = (lon2 - lon1) * pi / 180;
  final a = sin(dLat / 2) * sin(dLat / 2) +
      cos(lat1 * pi / 180) * cos(lat2 * pi / 180) *
      sin(dLon / 2) * sin(dLon / 2);
  final c = 2 * atan2(sqrt(a), sqrt(1 - a));
  return R * c;
}

步行时间估算

// 假设步行速度为5km/h
final walkingMinutes = distance * 12; // 1km约12分钟

9. 状态管理

使用setState进行局部状态更新,保持UI与数据同步。

状态变量

City? _selectedCity;              // 选中城市
List<SharedBike> _bikes = [];     // 所有车辆
List<SharedBike> _filteredBikes = []; // 筛选后车辆
String _filterBrand = '全部';      // 品牌筛选
String _filterType = '全部';       // 类型筛选
bool _onlyAvailable = false;      // 仅显示可用
double _maxDistance = 2.0;        // 最大距离

更新流程

  1. 用户操作触发事件
  2. 调用setState更新状态
  3. 重新构建受影响的Widget
  4. UI自动刷新

最佳实践

  • 只在setState中修改状态
  • 避免在build方法中修改状态
  • 合理拆分Widget减少重建范围

10. 空状态处理

当筛选结果为空时,显示友好的空状态提示。

_filteredBikes.isEmpty
    ? Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.pedal_bike,
                size: 80, color: Colors.grey[300]),
            const SizedBox(height: 16),
            Text('附近暂无车辆',
                style: TextStyle(color: Colors.grey[600])),
            const SizedBox(height: 16),
            FilledButton.icon(
              onPressed: () {
                setState(() {
                  _filterBrand = '全部';
                  _filterType = '全部';
                  _onlyAvailable = false;
                  _maxDistance = 5.0;
                });
                _applyFilters();
              },
              icon: const Icon(Icons.refresh),
              label: const Text('重置筛选'),
            ),
          ],
        ),
      )
    : ListView.builder(...)

空状态设计要点

  • 使用大图标吸引注意
  • 提供清晰的说明文字
  • 给出解决方案(重置筛选)
  • 保持视觉一致性

功能扩展方向

1. 地图集成

集成高德地图或百度地图SDK,在地图上显示车辆位置。

实现思路

  • 集成地图SDK(amap_flutter_map)
  • 在地图上标注车辆位置
  • 使用不同颜色标记不同品牌
  • 点击标记显示车辆信息
  • 支持地图缩放和拖动
  • 显示用户当前位置
  • 规划步行路线

代码示例

AMapWidget(
  markers: _bikes.map((bike) => Marker(
    position: LatLng(bike.latitude, bike.longitude),
    icon: BitmapDescriptor.fromAssetImage(
      'assets/bike_${bike.brand}.png',
    ),
  )).toSet(),
  onMapCreated: (controller) {
    _mapController = controller;
  },
)

2. 导航功能

提供从当前位置到车辆位置的导航功能。

实现思路

  • 获取用户当前位置
  • 调用地图导航API
  • 支持步行、骑行导航
  • 显示预计到达时间
  • 实时更新导航路线
  • 语音播报导航信息

技术方案

  • 使用geolocator获取位置
  • 使用url_launcher打开地图导航
  • 或集成地图SDK的导航功能

3. 车辆预约

支持提前预约车辆,避免被他人使用。

实现思路

  • 添加预约按钮
  • 设置预约时长(5-15分钟)
  • 预约期间锁定车辆
  • 倒计时显示剩余时间
  • 超时自动取消预约
  • 预约记录查询

数据模型

class BikeReservation {
  String bikeId;
  String userId;
  DateTime reserveTime;
  int duration; // 分钟
  String status; // reserved/expired/cancelled
}

4. 骑行历史

记录用户的骑行历史,包括路线、时长、费用等。

实现思路

  • 记录每次骑行数据
  • 显示骑行轨迹
  • 统计骑行里程
  • 计算总费用
  • 生成骑行报告
  • 支持数据导出

数据模型

class RideHistory {
  String id;
  String bikeId;
  String brand;
  DateTime startTime;
  DateTime endTime;
  double distance;
  double cost;
  List<LatLng> route;
}

5. 收藏功能

收藏常用的车辆停放点,快速查找。

实现思路

  • 添加收藏按钮
  • 保存收藏位置
  • 显示收藏列表
  • 快速导航到收藏点
  • 支持备注信息
  • 收藏点排序

数据模型

class FavoriteLocation {
  String id;
  String name;
  double latitude;
  double longitude;
  String note;
  DateTime createTime;
}

6. 价格对比

对比不同品牌在相同时长下的费用差异。

实现思路

  • 输入骑行时长
  • 计算各品牌费用
  • 柱状图对比显示
  • 推荐最优选择
  • 显示节省金额
  • 支持多种车型对比

UI设计

Column(
  children: [
    TextField(
      decoration: InputDecoration(labelText: '骑行时长(分钟)'),
      onChanged: (value) => _calculateAllPrices(int.parse(value)),
    ),
    ..._priceComparisons.map((item) => ListTile(
      leading: Icon(Icons.pedal_bike, color: item.brandColor),
      title: Text(item.brand),
      trailing: Text(${item.cost.toStringAsFixed(1)}'),
    )),
  ],
)

7. 通知提醒

设置各种通知提醒,提升用户体验。

实现思路

  • 附近有车提醒
  • 电量不足提醒
  • 费用超标提醒
  • 预约到期提醒
  • 优惠活动通知
  • 使用flutter_local_notifications

通知类型

  • 本地通知:定时提醒
  • 推送通知:实时消息
  • 应用内通知:弹窗提示

8. 数据统计

统计用户的骑行数据,生成可视化报表。

实现思路

  • 骑行次数统计
  • 总里程统计
  • 总费用统计
  • 品牌偏好分析
  • 时段分布图
  • 月度报告生成

图表展示

// 使用fl_chart绘制图表
LineChart(
  LineChartData(
    lineBarsData: [
      LineChartBarData(
        spots: _rideData.map((d) => FlSpot(d.day, d.count)).toList(),
        isCurved: true,
        colors: [Colors.blue],
      ),
    ],
  ),
)

常见问题解答

1. 如何实现实时位置更新?

问题:车辆位置需要实时更新,如何实现?

解答
在实际应用中,需要集成定位SDK和后端API:

// 使用geolocator获取位置
import 'package:geolocator/geolocator.dart';

Future<Position> _getCurrentLocation() async {
  bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
  if (!serviceEnabled) {
    return Future.error('位置服务未开启');
  }

  LocationPermission permission = await Geolocator.checkPermission();
  if (permission == LocationPermission.denied) {
    permission = await Geolocator.requestPermission();
    if (permission == LocationPermission.denied) {
      return Future.error('位置权限被拒绝');
    }
  }

  return await Geolocator.getCurrentPosition();
}

// 监听位置变化
StreamSubscription<Position> _positionStream = 
    Geolocator.getPositionStream().listen((Position position) {
  setState(() {
    _currentPosition = position;
    _updateBikeDistances();
  });
});

注意事项

  • 需要在AndroidManifest.xml和Info.plist中添加权限
  • 考虑电量消耗,合理设置更新频率
  • 处理权限被拒绝的情况

2. 如何处理车辆状态的实时同步?

问题:多个用户同时查看,如何保证状态一致?

解答
需要建立WebSocket连接或使用轮询机制:

// WebSocket方式
import 'package:web_socket_channel/web_socket_channel.dart';

class BikeService {
  late WebSocketChannel _channel;

  void connect() {
    _channel = WebSocketChannel.connect(
      Uri.parse('ws://api.example.com/bikes'),
    );

    _channel.stream.listen((message) {
      final data = jsonDecode(message);
      _updateBikeStatus(data);
    });
  }

  void _updateBikeStatus(Map<String, dynamic> data) {
    final bikeId = data['bikeId'];
    final status = data['status'];
    // 更新本地数据
  }
}

// 轮询方式
Timer.periodic(Duration(seconds: 30), (timer) {
  _fetchBikeUpdates();
});

推荐方案

  • 实时性要求高:使用WebSocket
  • 实时性要求一般:使用轮询(30-60秒)
  • 结合本地缓存减少网络请求

3. 如何集成支付功能?

问题:如何实现骑行费用的在线支付?

解答
集成第三方支付SDK(支付宝、微信支付):

// 使用tobias集成支付宝
import 'package:tobias/tobias.dart';

Future<void> _payWithAlipay(double amount) async {
  // 从服务器获取订单信息
  final orderInfo = await _getOrderInfo(amount);

  // 调起支付宝
  final result = await aliPay(orderInfo);

  if (result['resultStatus'] == '9000') {
    // 支付成功
    _showSuccessDialog();
  } else {
    // 支付失败
    _showErrorDialog(result['memo']);
  }
}

// 使用fluwx集成微信支付
import 'package:fluwx/fluwx.dart';

Future<void> _payWithWechat(double amount) async {
  // 从服务器获取预支付信息
  final prepayInfo = await _getPrepayInfo(amount);

  // 调起微信支付
  await sendWeChatPayment(
    appId: prepayInfo['appId'],
    partnerId: prepayInfo['partnerId'],
    prepayId: prepayInfo['prepayId'],
    packageValue: prepayInfo['package'],
    nonceStr: prepayInfo['nonceStr'],
    timeStamp: prepayInfo['timeStamp'],
    sign: prepayInfo['sign'],
  );

  // 监听支付结果
  weChatResponseEventHandler.listen((response) {
    if (response is WeChatPaymentResponse) {
      if (response.errCode == 0) {
        _showSuccessDialog();
      } else {
        _showErrorDialog(response.errStr);
      }
    }
  });
}

注意事项

  • 支付信息必须从服务器获取
  • 不要在客户端存储支付密钥
  • 做好支付结果的验证

4. 如何实现车辆搜索功能?

问题:用户想搜索特定编号或位置的车辆?

解答
添加搜索框和搜索逻辑:

class BikeSearchDelegate extends SearchDelegate<SharedBike?> {
  final List<SharedBike> bikes;

  BikeSearchDelegate(this.bikes);

  
  List<Widget> buildActions(BuildContext context) {
    return [
      IconButton(
        icon: const Icon(Icons.clear),
        onPressed: () => query = '',
      ),
    ];
  }

  
  Widget buildLeading(BuildContext context) {
    return IconButton(
      icon: const Icon(Icons.arrow_back),
      onPressed: () => close(context, null),
    );
  }

  
  Widget buildResults(BuildContext context) {
    final results = bikes.where((bike) {
      return bike.id.toLowerCase().contains(query.toLowerCase()) ||
          bike.address.toLowerCase().contains(query.toLowerCase());
    }).toList();

    return ListView.builder(
      itemCount: results.length,
      itemBuilder: (context, index) {
        final bike = results[index];
        return ListTile(
          leading: Icon(bike.typeIcon, color: bike.brandColor),
          title: Text(bike.id),
          subtitle: Text(bike.address),
          trailing: Text(bike.statusText),
          onTap: () => close(context, bike),
        );
      },
    );
  }

  
  Widget buildSuggestions(BuildContext context) {
    return buildResults(context);
  }
}

// 使用搜索
IconButton(
  icon: const Icon(Icons.search),
  onPressed: () async {
    final result = await showSearch(
      context: context,
      delegate: BikeSearchDelegate(_bikes),
    );
    if (result != null) {
      _showBikeDetail(result);
    }
  },
)

搜索功能

  • 按车辆编号搜索
  • 按地址搜索
  • 按品牌搜索
  • 支持模糊匹配
  • 显示搜索历史

5. 如何添加用户评价功能?

问题:用户想对骑行体验进行评价?

解答
添加评价对话框和评价列表:

class BikeReview {
  String bikeId;
  String userId;
  double rating;
  String comment;
  DateTime createTime;
}

Future<void> _showReviewDialog(SharedBike bike) async {
  double rating = 5.0;
  String comment = '';

  await showDialog(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('评价车辆'),
      content: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: List.generate(5, (index) {
              return IconButton(
                icon: Icon(
                  index < rating ? Icons.star : Icons.star_border,
                  color: Colors.amber,
                ),
                onPressed: () {
                  setState(() => rating = index + 1.0);
                },
              );
            }),
          ),
          TextField(
            decoration: const InputDecoration(
              labelText: '评价内容',
              hintText: '分享您的骑行体验',
            ),
            maxLines: 3,
            onChanged: (value) => comment = value,
          ),
        ],
      ),
      actions: [
        TextButton(
          onPressed: () => Navigator.pop(context),
          child: const Text('取消'),
        ),
        FilledButton(
          onPressed: () {
            _submitReview(bike.id, rating, comment);
            Navigator.pop(context);
          },
          child: const Text('提交'),
        ),
      ],
    ),
  );
}

void _submitReview(String bikeId, double rating, String comment) {
  final review = BikeReview(
    bikeId: bikeId,
    userId: 'current_user_id',
    rating: rating,
    comment: comment,
    createTime: DateTime.now(),
  );
  // 保存到本地或上传到服务器
}

评价功能

  • 星级评分(1-5星)
  • 文字评价
  • 图片上传
  • 评价列表展示
  • 评价统计分析

项目总结

核心功能流程

筛选

刷新

查看详情

查看费用

启动应用

选择城市

生成车辆数据

显示车辆列表

用户操作

应用筛选条件

更新车辆状态

显示详情页

显示费用列表

开始骑行?

开始计费

数据流转

数据层 状态管理 界面 用户 数据层 状态管理 界面 用户 选择城市 更新选中城市 生成车辆数据 返回车辆列表 更新界面 显示车辆 设置筛选条件 更新筛选参数 应用筛选 返回筛选结果 更新列表 显示筛选结果 点击车辆 获取车辆详情 查询费用信息 返回详情数据 显示详情页 展示详细信息

技术架构

Flutter应用

UI层

业务逻辑层

数据层

主页面

详情页

费用页

状态管理

筛选逻辑

费用计算

数据模型

数据生成

数据持久化

项目特色

  1. 多城市支持:覆盖8个主要城市,数据独立管理
  2. 智能筛选:品牌、类型、距离、可用性多维度筛选
  3. 实时更新:模拟车辆状态和电量的实时变化
  4. 费用透明:详细的计费规则和费用预估
  5. 用户体验:Material Design 3设计,交互流畅
  6. 数据可视化:电量进度条、汇总卡片等可视化元素
  7. 响应式布局:适配不同屏幕尺寸
  8. 空状态处理:友好的空状态提示和引导

学习收获

通过本项目,你将掌握:

  1. Flutter基础:Widget组合、状态管理、导航路由
  2. Material Design:卡片、芯片、进度条等组件使用
  3. 数据处理:列表筛选、排序、计算属性
  4. 交互设计:ModalBottomSheet、对话框、手势操作
  5. UI设计:颜色搭配、布局设计、视觉层次
  6. 业务逻辑:费用计算、距离计算、状态更新
  7. 代码组织:模型设计、方法封装、代码复用

性能优化建议

  1. 列表优化:使用ListView.builder实现懒加载
  2. 状态管理:合理使用setState,避免不必要的重建
  3. 图片优化:使用Icon代替图片,减少资源占用
  4. 数据缓存:缓存筛选结果,避免重复计算
  5. 异步操作:使用Future和async/await处理耗时操作
  6. 内存管理:及时释放不用的资源和监听器

后续优化方向

  1. 真实数据:接入后端API,获取真实车辆数据
  2. 地图显示:集成地图SDK,可视化车辆位置
  3. 导航功能:提供到车辆的导航路线
  4. 支付集成:接入支付宝、微信支付
  5. 用户系统:登录注册、个人中心、骑行记录
  6. 推送通知:车辆提醒、优惠活动通知
  7. 数据统计:骑行数据分析、图表展示
  8. 社交功能:分享骑行、好友互动

本项目提供了一个完整的共享单车查询应用框架,你可以在此基础上继续扩展功能,打造更加完善的共享出行应用。

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐