Skip to content

WangShuan/flutter-06-favorite-places

Repository files navigation

使用 flutter 建構 Favorite Places APP

筆記連結:https://hackmd.io/qi4A57mdT6yFOw-IFmdqgA?view

學習如何使用設備本身的功能(相機、相簿、定位當前位置)、如何使用 Google Map 在應用程式中顯示地圖與標記位置、如何將數據保存在設備中。

會用到的 pub 介紹

  • flutter_riverpod: 用於狀態管理
  • uuid: 用來產生隨機 id
  • image_picker: 拍攝照片或從相簿選取圖片並取得檔案來使用
  • location: 獲取當前位置經、緯度來使用
  • flutter_config: 用來在專案中存取 .env 檔案
  • http: 與 Google API 進行交互,以獲取經、緯度所對應的地址資訊
  • google_maps_flutter: 在應用程式中顯示 Google Map
  • path_provider: 用於查找文件系統上的常用位置以保存數據
  • path: 用來簡化操作路徑的方法
  • sqflite: 使用 SQL 命令在設備上存儲數據

建立雛型

先建立 models/place.dart 設置地點的藍圖:

import 'dart:io';

import 'package:uuid/uuid.dart'; // 引入 uuid

class PlaceLocation { // 設置位置資訊要用的藍圖
  final double lat; // 保存經度
  final double lng; // 保存緯度
  final String? address; // 保存地址資訊

  const PlaceLocation({
    required this.lat,
    required this.lng,
    this.address,
  });
}

class Place { // 設置地點的藍圖
  final String id; // 要有 id
  final String title; // 標題
  final File image; // 圖片
  final PlaceLocation location; // 位置資訊

 // Place 的 id 用 uuid 生成預設值
 Place(this.title, this.image, this.location) : id = const Uuid().v4();
}

畫面分析

  • 首頁畫面:用列表形式顯示所有地點,主要放置圖片縮圖、標題與地址資訊。
    • 可通過 ListView.builder 建立
  • 地點詳細介紹畫面:將圖片設為滿版背景,顯示標題與地址資訊在畫面下方、放置地圖縮圖點擊後開啟地圖畫面。
    • 使用 Stack 建立滿版背景圖再搭配 Positioned 放置文字資訊在 bottom 的位置
    • 地圖縮圖用 CircleAvatar 製作(可設置 radius 調整圓的大小),父層可使用 GestureDetector 以綁定點擊事件
  • 新增地點畫面:顯示標題欄位、拍攝或選取圖片並預覽、定位或選取地點並預覽、提交按鈕。
    • 可用 Form 小部件處理標題欄位,也可直接用 TextField 小部件
    • 通過 await ImagePicker().pickImage(source: ImageSource.gallery) 從相簿選取圖片檔案
    • 通過 await ImagePicker().pickImage(source: ImageSource.camera) 開啟相機拍攝照片
    • 通過 await location.getLocation() 獲取當前定位
    • 使用 Google 提供的 API https://maps.googleapis.com/maps/api/geocode/json?latlng=$lat,$lng&key=$apiKey 可獲取地址資訊
    • 使用 Google 提供的網址 https://maps.googleapis.com/maps/api/staticmap?center=$lat,$long&scale=2&zoom=15&size=400x300&key=$apiKey&markers=color:red%7Clabel:%7C$lat%2C$long 可顯示地圖截圖
  • 地圖畫面:顯示地圖、在定位的位置上顯示 Marker 。
    • 通過 GoogleMap 小部件,傳入 initialCameraPosition 即可顯示經、緯度座標位於正中央的 Google 地圖畫面
    • GoogleMap 小部件,傳入 markers 即可在畫面中顯示 Marker
    • GoogleMap 小部件,設置 onTap 事件即可保存選中位置的座標

主要程式碼片段

地點詳細介紹畫面

檔案路徑: lib/screens/place_screen.dart

Scaffold(
  extendBodyBehindAppBar: true, // 設置背景延伸到 AppBar 下方
  appBar: AppBar(
    backgroundColor: Colors.transparent, // 讓 AppBar 背景色為透明
  ),
  body: Stack( // 使用 Stack 小部件,讓子部件產生交疊
    children: [
      Hero( // 通過 Hero 設置屏幕之間圖片的過場效果
        tag: place.id,
        child: Image.file(
          place.image,
          fit: BoxFit.cover, // 設置圖片 cover 填滿大小
          width: double.infinity, // 設置為最大寬度填滿畫面
          height: double.infinity, // 設置為最大高度填滿畫面
        ),
      ),
      Positioned( // 使用 Positioned 小部件,讓子部件放在絕對定位上
        bottom: 0, // 設置定位在最底部
        left: 0, right: 0, // 同時設置 left: 0, right: 0 可讓該部件填滿父層寬度
        child: Container( // 利用 Container 做出漸層色背景顯示資訊
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              colors: [
                Colors.transparent,
                Colors.black,
              ],
              begin: Alignment.topCenter,
              end: Alignment.bottomCenter,
            ),
          ),
          padding: const EdgeInsets.fromLTRB(16, 0, 16, 32),
          child: Column(
            children: [
              GestureDetector( // 利用 GestureDetector 小部件綁定點擊事件
                onTap: () => Navigator.of(context).push(MaterialPageRoute(
                  fullscreenDialog: true, // 顯示由下往上滑的畫面,且原本的返回箭頭會變為關閉按鈕
                  builder: (context) => MapScreen(
                    isSelecting: false,
                    initLocation: PlaceLocation(lat: place.location.lat, lng: place.location.lng, address: place.location.address),
                  ),
                )),
                child: CircleAvatar( // 用 CircleAvatar 做出原型地圖縮圖
                  radius: 80, // 設置圓形大小
                  backgroundImage: NetworkImage(
                      'https://maps.googleapis.com/maps/api/staticmap?center=${place.location.lat},${place.location.lng}&scale=2&zoom=15&size=400x300&key=$googleApiKey&markers=color:red%7Clabel:%7C${place.location.lat}%2C${place.location.lng}'),
                ),
              ),
              Text( // 顯示標題
                place.title,
                style: Theme.of(context).textTheme.headlineMedium?.copyWith(color: colorScheme.primary),
              ),
              Text( // 顯示地址資訊
                place.location.address!,
                style: Theme.of(context).textTheme.bodyLarge?.copyWith(color: colorScheme.secondary, fontSize: 20),
                textAlign: TextAlign.center,
              ),
            ],
          ),
        ),
      )
    ],
  ),
);

圖片選取及預覽區塊

這邊將新增地點中的『圖片選取及預覽』區塊,獨立拉出來做一個客製化小部件 ImgaeInput

class ImageInput extends StatefulWidget {
  const ImageInput(this.selectedImg, {super.key});
  // 這邊設定一個 selectedImg 是從父層傳入的函數,用來將 File img 傳給父層使用
  final void Function(File img) selectedImg;

  @override
  State<ImageInput> createState() => _ImageInputState();
}

class _ImageInputState extends State<ImageInput> {
  File? _selectedImg; // 宣告一個變數用來存放選中的圖片檔案
  @override
  Widget build(BuildContext context) {
    return Container( // 用 Container 做一個帶邊框與背景色的區塊
      width: double.infinity,
      height: 200,
      decoration: BoxDecoration(
        color: colorScheme.primary.withOpacity(0.3),
        border: Border.all(color: colorScheme.primary),
        borderRadius: BorderRadius.circular(16),
      ),
      child: Stack( // 通過 Stack 讓子部件互相交疊
        alignment: Alignment.bottomCenter, // 設置對齊方式為下方的正中央
        children: [
          if (_selectedImg != null) // 判斷使否有選中的圖片
            ClipRRect( // 用 ClipRRect 小部件做圓角矩形
              borderRadius: BorderRadius.circular(16),
              child: Image.file( // 顯示圖片檔案
                _selectedImg!,
                fit: BoxFit.cover,
                width: double.infinity,
                height: double.infinity,
              ),
            ),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              FilledButton.icon(
                style: FilledButton.styleFrom(backgroundColor: colorScheme.primary.withOpacity(0.7)),
                onPressed: () async { // 點擊開啟相機
                  // ImageSource.camera 表示拍攝照片、maxWidth 用來設置圖片最大寬度
                  final img = await ImagePicker().pickImage(source: ImageSource.camera, maxWidth: 600);
                  if (img == null) {
                    return;
                  }
                  setState(() { // 將拍攝的照片路徑傳給 File 小部件做成 File 並保存到 _selectedImg 中
                    _selectedImg = File(img.path);
                  });
                  widget.selectedImg(_selectedImg!); // 呼叫父層傳來的 selectedImg 把拍攝的照片傳出去父層做使用
                },
                icon: const Icon(Icons.camera_alt),
                label: const Text('拍攝照片'),
              ),
              Text(
                '或',
                style: TextStyle(color: colorScheme.primary),
              ),
              FilledButton.icon(
                style: FilledButton.styleFrom(backgroundColor: colorScheme.primary.withOpacity(0.7)),
                onPressed: () async {
                  // ImageSource.gallery 表示從照片庫中選擇檔案、maxWidth 用來設置圖片最大寬度
                  final img = await ImagePicker().pickImage(source: ImageSource.gallery, maxWidth: 600);
                  if (img == null) {
                    return;
                  }
                  setState(() { // 將選中的圖片路徑傳給 File 小部件做成 File 並保存到 _selectedImg 中
                    _selectedImg = File(img.path);
                  });
                  widget.selectedImg(_selectedImg!); // 呼叫父層傳來的 selectedImg 把選中的圖片傳出去父層做使用
                },
                icon: const Icon(Icons.image_rounded),
                label: const Text('從相簿中選擇'),
              ),
            ],
          )
        ],
      ),
    );
  }
}

由於目前版本的 image_picker 無法在 IOS 模擬器中開啟相機,如需使用相機功能,需要使用真實 iPhone 設備。 可通過 iPhone 充電線連接電腦,並將手機的『開發者模式』打開(設定>安全性>開法者模式,開啟會要求重新啟動手機),再用 VS Code 選擇自己的 iPhone 啟動 flutter 即可測試應用程式。

定位或選取地點及預覽區塊

首先將 Google API Key 設為 .env

  1. 安裝 flutter_config
flutter pub add flutter_config
  1. main.dart 中初始化:
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await FlutterConfig.loadEnvVariables();

  runApp(
    const ProviderScope(
      child: MyApp(),
    ),
  );
}
  1. 建立環境變量檔案 .env
GOOGLE_API_KEY=AAAaaaBBBbbbCCCcccDDDdddEEEeeeFFFfffGGG
  1. 於需要使用的地方獲取環境變量:
final googleApiKey = FlutterConfig.get('GOOGLE_API_KEY');

接著將獲取經、緯度以及利用經、緯度取得地址資訊的函數抽出來,做成 location_provider.dart 中的方法以方便取用:

import 'dart:convert';

import 'package:http/http.dart' as http;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:location/location.dart';
import 'package:flutter_config/flutter_config.dart';

final googleApiKey = FlutterConfig.get('GOOGLE_API_KEY'); // 獲取 env

class LocationNotifier extends StateNotifier {
  LocationNotifier() : super(''); // 設置初始值

  // 設置獲取當前位置的方法
  Future<LocationData> getUserLocation() async {
    Location location = Location();
    
    // 判斷是否有取得定位的權限
    bool serviceEnabled;
    PermissionStatus permissionGranted;
    LocationData locationData;

    try {
      serviceEnabled = await location.serviceEnabled();
      if (!serviceEnabled) {
        serviceEnabled = await location.requestService();
        if (!serviceEnabled) {
          throw Exception('無法取得當前位置。');
        }
      }

      permissionGranted = await location.hasPermission();
      if (permissionGranted == PermissionStatus.denied) {
        permissionGranted = await location.requestPermission();
        if (permissionGranted != PermissionStatus.granted) {
          throw Exception('無法取得當前位置。');
        }
      }

      // 獲取當前所在位置
      locationData = await location.getLocation();
    } catch (err) {
      throw Exception('無法取得當前位置。');
    }
    
    // 回傳當前所在位置
    return locationData;
  }

  // 設置獲取地址資訊的方法
  Future<String> getAddress(double lat, double long) async {
    // 發送 get 請求
    final res = await http.get(
      Uri.https('maps.googleapis.com', '/maps/api/geocode/json', {
        "latlng": "$lat,$long", // 傳入經緯度
        "key": googleApiKey, // 傳入 apiKey
        "language": "zh-TW", // 設置語言
      }),
    );
    // 回傳地址資訊
    return json.decode(res.body)['results'][0]['formatted_address'];
  }
}

final locationProvider = StateNotifierProvider(
  (ref) {
    return LocationNotifier();
  },
);

最後把新增地點畫面中的『定位/選取地點及預覽』區塊,獨立拉出來做一個客製化小部件 LocationInput

// 把有狀態小部件改為 ConsumerStatefulWidget 小部件,以使用 provider
class LocationInput extends ConsumerStatefulWidget {
  const LocationInput(this.setLocation, {super.key});
  // 從父層獲取 setLocation 函數用來保存地址資訊並傳給父層使用
  final void Function(double lat, double long, String address) setLocation;

  @override
  ConsumerState<LocationInput> createState() => _LocationInputState();
}

class _LocationInputState extends ConsumerState<LocationInput> {
  // 獲取 env 用於顯示地圖截圖
  final googleApiKey = FlutterConfig.get('GOOGLE_API_KEY');
  // 宣告參數用來存放地圖截圖網址
  String? img;

  // 獲取用戶當前位置
  Future<void> _getUserLocation() async {
    LocationData locaData;
    try {
      locaData = await ref.read(locationProvider.notifier).getUserLocation();
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('無法取得當前位置。')));
      return;
    }

    final lat = locaData.latitude;
    final long = locaData.longitude;

    if (lat == null || long == null) return;

    _setAddressAndShowImg(lat, long);
  }

  // 獲取用戶於地圖中選擇的位置
  Future<void> _selectOnMap() async {
    LatLng locaData;
    try {
      // 開啟地圖畫面並接收回傳的經緯度
      locaData = await Navigator.of(context).push(MaterialPageRoute(
        fullscreenDialog: true,
        builder: (context) => const MapScreen(
          isSelecting: true,
        ),
      ));
    } catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('無法取得位置資訊。')));
      return;
    }

    _setAddressAndShowImg(locaData.latitude, locaData.longitude);
  }

  // 用來獲取地址資訊及設置地圖截圖
  void _setAddressAndShowImg(lat, long) async {
    final address = await ref.read(locationProvider.notifier).getAddress(lat, long);

    setState(() {
      img = 'https://maps.googleapis.com/maps/api/staticmap?center=$lat,$long&scale=2&zoom=15&size=400x300&key=$googleApiKey&markers=color:red%7Clabel:%7C$lat%2C$long';
    });

    widget.setLocation(lat, long, address); // 呼叫父層函數以保存地址資訊
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      width: double.infinity,
      height: 200,
      decoration: BoxDecoration(
        color: colorScheme.primary.withOpacity(0.3),
        border: Border.all(color: colorScheme.primary),
        borderRadius: BorderRadius.circular(16),
      ),
      child: Stack(
        alignment: Alignment.bottomCenter,
        children: [
          if (img != null)
            ClipRRect(
              borderRadius: BorderRadius.circular(6),
              child: FadeInImage.assetNetwork(
                placeholder: 'assets/images/location-placeholder.gif',
                image: img!,
                fit: BoxFit.cover,
                width: double.infinity,
              ),
            ),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              FilledButton.icon(
                style: buttonStyle,
                onPressed: _getUserLocation,
                icon: const Icon(Icons.my_location_outlined),
                label: const Text('我的位置'),
              ),
              Text(
                '或',
                style: TextStyle(color: colorScheme.primary),
              ),
              FilledButton.icon(
                style: buttonStyle,
                onPressed: _selectOnMap,
                icon: const Icon(Icons.map_rounded),
                label: const Text('從地圖中選擇'),
              ),
            ],
          )
        ],
      ),
    );
  }
}

新增地點畫面

這邊主要分享 Form 小部件內容:

Form(
  key: formKey,
  child: SingleChildScrollView(
    padding: const EdgeInsets.all(16),
    child: Column(
      children: [
        TextFormField( // 用 TextFormField 處理地點名稱的驗證與提交
          decoration: const InputDecoration(
            labelText: '地點名稱',
            contentPadding: EdgeInsets.zero,
          ),
          maxLength: 8,
          validator: (val) => val == null || val.isEmpty || val.trim().length < 2 ? 'Name must be at least 2 characters.' : null,
          onSaved: (newValue) => name = newValue!,
        ),
        const SizedBox(height: 16),
        // 使用自定義小部件 ImageInput 並將選中的圖片檔案設為 image ,於提交表單時才可傳到 Plcae 中
        ImageInput((img) => image = img),
        const SizedBox(height: 16),
        // 使用自定義小部件 LocationInput 並將選中的地點資訊設為 PlaceLocation ,於提交表單時才可傳到 Plcae 中
        LocationInput((lat, long, address) => local = PlaceLocation(lat: lat, lng: long, address: address)),
        const SizedBox(height: 16),
        SizedBox(
          width: double.infinity,
          child: FilledButton(
            onPressed: submitForm,
            child: const Text('Submit'),
          ),
        )
      ],
    ),
  ),
)

地圖畫面

class MapScreen extends StatefulWidget {
  const MapScreen({
    super.key,
    this.initLocation = const PlaceLocation(
      lat: 25.10235351700014,
      lng: 121.54849200004878,
    ),
    this.isSelecting = true,
  });
  final bool isSelecting; // 設置參數用來判斷是否要選取地點
  final PlaceLocation initLocation; // 設定預設的經緯度位置

  @override
  State<MapScreen> createState() => _MapScreenState();
}

class _MapScreenState extends State<MapScreen> {
  LatLng? _markerLocation; // 用來存放選中的地點

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.isSelecting ? '請選擇地點' : widget.initLocation.address!),
        actions: [
          if (widget.isSelecting) // 如果是選地點的模式就顯示按鈕
            IconButton( // 放置一個保存按鈕,將選中的地點傳回新增地點畫面
              onPressed: () => Navigator.of(context).pop(_markerLocation),
              icon: const Icon(Icons.save_rounded),
            )
        ],
      ),
      body: GoogleMap( // 顯示 GoogleMap
        initialCameraPosition: CameraPosition( // 設置初始經緯度
          target: LatLng(
            widget.initLocation.lat,
            widget.initLocation.lng,
          ),
          zoom: 16, // 設定縮放等級
        ),
        onTap: widget.isSelecting // 如果是選地點的模式,點擊事件就把選中的位置保存到 _markerLocation 中
            ? (posi) {
                setState(() {
                  _markerLocation = posi;
                });
              }
            : null,
        markers: (_markerLocation != null || widget.isSelecting == false) // 如果以選中地點或不是選地點的模式就顯示 Marker
            ? {
                Marker(
                  markerId: const MarkerId('m1'), // 設定 ID
                  position: _markerLocation ?? LatLng(widget.initLocation.lat, widget.initLocation.lng), // 設定放置 Marker 的經緯度位置
                ),
              }
            : {},
      ),
    );
  }
}

將數據保存到設備中

  1. 將圖片保存到設備中:改寫 image_input.dart 中選取圖片的函數,將選中的圖片檔案拷貝到設備中,最終傳給父層 Place 對象的 File 為拷貝到設備中檔案,可確保檔案不會因記憶體爆掉被刪除。
import 'package:path_provider/path_provider.dart' as syspaths; // 引入 path_provider 為 syspaths
import 'package:path/path.dart' as path; // 引入 path 為 path

// 撰寫選取圖片的函數,傳入布林值判斷是否為拍攝照片
void onSelectedImg(bool isTakePicture) async {
  final ImagePicker picker = ImagePicker();
  final img = await picker.pickImage(source: isTakePicture ? ImageSource.camera : ImageSource.gallery, maxWidth: 600);
  
  if (img == null) {
    return;
  }
  
  setState(() {
    _selectedImg = File(img.path);
  });

  // 使用 syspaths.getApplicationDocumentsDirectory() 獲取設備資料夾
  final appDir = await syspaths.getApplicationDocumentsDirectory();
  // 使用 path.basename 傳入 img 的路徑,以獲取 img 的檔案名稱
  final filename = path.basename(img.path);
  // 通過 .copy() 方法傳入要放置的位置,將圖片檔案拷貝到設備資料夾中
  final copyImg = await _selectedImg!.copy('${appDir.path}/$filename');
  
  // 最後將拷貝好的圖片檔案傳給父層使用
  widget.selectedImg(copyImg);
}
  1. 將數據保存到設備中:改寫 places_provider.dart 中的方法

聲明一個方法獨立於 PlacesNotifier 外面,用來獲取 db :

import 'package:path/path.dart' as path; // 引入 path 為 path 下面需用到 path.join 方法
import 'package:sqflite/sqflite.dart' as sql; // 引入 sqflite 為 sql

// 宣告一個獨立的方法用來獲取 db
Future<sql.Database> _getDb() async {
  final dbPath = await sql.getDatabasesPath(); // 通過 getDatabasesPath 獲取 db 路徑
  final db = await sql.openDatabase( // 通過 openDatabase 開啟 db,如不存在就建立
    path.join(dbPath, 'places.db'), // 設置要開啟的 db,這邊通過 path.join 方法在 db 中添加一個 places.db 的資料庫
    onCreate: (db, version) { // 設置 onCreate 方法,通過 db.execute 建立名為 places 的 table
      return db.execute('CREATE TABLE places(id TEXT PRIMARY KEY, title TEXT, image TEXT, lat REAL, lng REAL, address TEXT)');
    },
    version: 1, // 設定版本為 1
  );
  return db; // 最後回傳 db
}

addPlace 中將資料 insert 到資料庫中:

Future<void> addPlace(Place place) async {
  final db = await _getDb(); // 獲取 db

  db.insert('places', { // 通過 db.insert 添加資料
    'id': place.id, // 傳入 id
    'title': place.title, // 傳入 title
    'image': place.image.path, // 傳入 image 的路徑
    'lat': place.location.lat, // 傳入 lat
    'lng': place.location.lng, // 傳入 lng
    'address': place.location.address, // 傳入 address
  });

  state = [place, ...state]; // 設置 state 為新的數據
}

添加 getPlaces 方法,從資料庫獲取數據:

Future<void> getData() async {
  final db = await _getDb(); // 獲取 db
  final data = await db.query('places'); // 通過 query 方法傳入 table 名獲取所有數據
  
  // 設置 state 為整理後的數據資料
  state = data
      .map( // 通過 map 方法取出所有資料並建立 Place 對象
        (e) => Place(
          id: e['id'] as String,
          title: e['title'] as String,
          image: File(e['image'] as String), // 因為是存 image 的路徑所以要用 File 包起來
          location: PlaceLocation(
            lat: e['lat'] as double,
            lng: e['lng'] as double,
            address: e['address'] as String,
          ),
        ),
      )
      .toList(); // 最後記得要 toList() 轉回陣列
}

最後在 places_screen.dart 中,將 ListView.builderFutureBuilder 包起來,於初次執行 getData 方法初始化資料即可:

class _PlacesScreenState extends ConsumerState<PlacesScreen> {
  late Future _placesFuture; // 宣告一個稍後賦值的 _placesFuture
  
  @override
  void initState() { // 設置 initState 方法初始化 _placesFuture
    _placesFuture = ref.read(placesProvider.notifier).getData();
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    final places = ref.watch(placesProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('我的所有秘密基地'),
        actions: [
          IconButton(
              onPressed: () async {
                final p = await Navigator.of(context).push<Place>(MaterialPageRoute(
                  builder: (context) => const NewPlaceScreen(),
                ));
                ref.read(placesProvider.notifier).addPlace(p!);
              },
              icon: const Icon(Icons.add))
        ],
      ),
      body: FutureBuilder( // 添加 FutureBuilder 小部件在最外層
        future: _placesFuture, // 傳入稍早宣告的 _placesFuture
        builder: (context, snapshot) => snapshot.connectionState == ConnectionState.waiting
            ? const Center(
                child: CircularProgressIndicator(),
              )
            : places.isEmpty
                ? const Center(
                    child: Text('尚未添加任何地點。'),
                  )
                : ListView.builder(
                    itemBuilder: (context, index) => PlaceItem(places[index]),
                    itemCount: places.length,
                  ),
      ),
    );
  }
}

補充於 AppDelegate.swift 中取用 env 的方法:

// 引入 flutter_config
import flutter_config

// 使用
GMSServices.provideAPIKey(FlutterConfigPlugin.env(for: "GOOGLE_API_KEY"))

About

Learn how to use image_picker & Google map & store data into the device.

Topics

Resources

Stars

Watchers

Forks

pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy