From bef23b8426c6eba007f6fa1417d56239c346200f Mon Sep 17 00:00:00 2001 From: ZhuJHua <1624109111@qq.com> Date: Thu, 7 Nov 2024 02:05:57 +0800 Subject: [PATCH] feat(map): Add map support for viewing and managing diary entry locations Closes #13 --- devtools_options.yaml | 3 + lib/api/api.dart | 43 +++++--- lib/common/models/geo.dart | 106 ++++++++++++++++++++ lib/common/models/map.dart | 14 +++ lib/components/bubble/bubble_view.dart | 72 +++++++++++++ lib/components/side_bar/side_bar_logic.dart | 16 +-- lib/main.dart | 5 +- lib/pages/edit/edit_logic.dart | 50 ++++++--- lib/pages/edit/edit_view.dart | 4 +- lib/pages/map/map_logic.dart | 27 +++++ lib/pages/map/map_state.dart | 9 +- lib/pages/map/map_view.dart | 75 ++++++++++++-- pubspec.lock | 40 ++++++++ pubspec.yaml | 1 + 14 files changed, 416 insertions(+), 49 deletions(-) create mode 100644 devtools_options.yaml create mode 100644 lib/common/models/geo.dart create mode 100644 lib/common/models/map.dart create mode 100644 lib/components/bubble/bubble_view.dart diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/lib/api/api.dart b/lib/api/api.dart index c951bf0..da9aa57 100644 --- a/lib/api/api.dart +++ b/lib/api/api.dart @@ -5,6 +5,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:geolocator/geolocator.dart'; import 'package:get/get.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:mood_diary/common/models/geo.dart'; import 'package:mood_diary/common/models/github.dart'; import 'package:mood_diary/common/models/hitokoto.dart'; import 'package:mood_diary/common/models/hunyuan.dart'; @@ -49,14 +51,12 @@ class Api { return (await Utils().httpUtil.get(url, type: ResponseType.bytes)).data; } - Future?> updateWeather() async { + Future?> updatePosition() async { Position? position; - if (await Utils().permissionUtil.checkPermission(Permission.location)) { position = await Geolocator.getLastKnownPosition(forceAndroidLocationManager: true); position ??= await Geolocator.getCurrentPosition(locationSettings: AndroidSettings(forceLocationManager: true)); } - if (position != null) { var local = Localizations.localeOf(Get.context!); var parameters = { @@ -65,21 +65,38 @@ class Api { 'key': Utils().prefUtil.getValue('qweatherKey'), 'lang': local }; - var res = await Utils().httpUtil.get('https://devapi.qweather.com/v7/weather/now', parameters: parameters); - var weather = await compute(WeatherResponse.fromJson, res.data as Map); - if (weather.now != null) { - return [ - weather.now!.icon!, - weather.now!.temp!, - weather.now!.text!, - ]; + var res = await Utils().httpUtil.get('https://geoapi.qweather.com/v2/city/lookup', parameters: parameters); + var geo = await compute(GeoResponse.fromJson, res.data as Map); + if (geo.location != null && geo.location!.isNotEmpty) { + var city = geo.location!.first; + return [position.latitude.toString(), position.longitude.toString(), '${city.adm2} ${city.name}']; } else { return null; } } else { - Utils().noticeUtil.showToast('定位失败'); + return null; + } + } + + Future?> updateWeather({required LatLng position}) async { + var local = Localizations.localeOf(Get.context!); + var parameters = { + 'location': + '${double.parse(position.longitude.toStringAsFixed(2))},${double.parse(position.latitude.toStringAsFixed(2))}', + 'key': Utils().prefUtil.getValue('qweatherKey'), + 'lang': local + }; + var res = await Utils().httpUtil.get('https://devapi.qweather.com/v7/weather/now', parameters: parameters); + var weather = await compute(WeatherResponse.fromJson, res.data as Map); + if (weather.now != null) { + return [ + weather.now!.icon!, + weather.now!.temp!, + weather.now!.text!, + ]; + } else { + return null; } - return null; } Future getGithubRelease() async { diff --git a/lib/common/models/geo.dart b/lib/common/models/geo.dart new file mode 100644 index 0000000..de62685 --- /dev/null +++ b/lib/common/models/geo.dart @@ -0,0 +1,106 @@ +class GeoResponse { + String? code; + List? location; + Refer? refer; + + GeoResponse({this.code, this.location, this.refer}); + + GeoResponse.fromJson(Map json) { + code = json["code"]; + location = json["location"] == null ? null : (json["location"] as List).map((e) => Location.fromJson(e)).toList(); + refer = json["refer"] == null ? null : Refer.fromJson(json["refer"]); + } + + Map toJson() { + final Map data = {}; + data["code"] = code; + data["location"] = location?.map((e) => e.toJson()).toList(); + data["refer"] = refer?.toJson(); + return data; + } +} + +class Refer { + List? sources; + List? license; + + Refer({this.sources, this.license}); + + Refer.fromJson(Map json) { + sources = json["sources"] == null ? null : List.from(json["sources"]); + license = json["license"] == null ? null : List.from(json["license"]); + } + + Map toJson() { + final Map data = {}; + data["sources"] = sources; + data["license"] = license; + return data; + } +} + +class Location { + String? name; + String? id; + String? lat; + String? lon; + String? adm2; + String? adm1; + String? country; + String? tz; + String? utcOffset; + String? isDst; + String? type; + String? rank; + String? fxLink; + + Location({ + this.name, + this.id, + this.lat, + this.lon, + this.adm2, + this.adm1, + this.country, + this.tz, + this.utcOffset, + this.isDst, + this.type, + this.rank, + this.fxLink, + }); + + Location.fromJson(Map json) { + name = json["name"]; + id = json["id"]; + lat = json["lat"]; + lon = json["lon"]; + adm2 = json["adm2"]; + adm1 = json["adm1"]; + country = json["country"]; + tz = json["tz"]; + utcOffset = json["utcOffset"]; + isDst = json["isDst"]; + type = json["type"]; + rank = json["rank"]; + fxLink = json["fxLink"]; + } + + Map toJson() { + final Map data = {}; + data["name"] = name; + data["id"] = id; + data["lat"] = lat; + data["lon"] = lon; + data["adm2"] = adm2; + data["adm1"] = adm1; + data["country"] = country; + data["tz"] = tz; + data["utcOffset"] = utcOffset; + data["isDst"] = isDst; + data["type"] = type; + data["rank"] = rank; + data["fxLink"] = fxLink; + return data; + } +} diff --git a/lib/common/models/map.dart b/lib/common/models/map.dart new file mode 100644 index 0000000..67bb6fb --- /dev/null +++ b/lib/common/models/map.dart @@ -0,0 +1,14 @@ +import 'package:latlong2/latlong.dart'; + +class DiaryMapItem { + // 坐标 + late LatLng latLng; + + // 文章id + late int id; + + // 封面图片名称 + late String coverImageName; + + DiaryMapItem(this.latLng, this.id, this.coverImageName); +} diff --git a/lib/components/bubble/bubble_view.dart b/lib/components/bubble/bubble_view.dart new file mode 100644 index 0000000..ddd8f0c --- /dev/null +++ b/lib/components/bubble/bubble_view.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; + +class Bubble extends StatelessWidget { + final Widget child; + final Color backgroundColor; + final double borderRadius; + + const Bubble({ + super.key, + required this.child, + this.backgroundColor = Colors.white, + this.borderRadius = 8.0, + }); + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: BubblePainter( + color: backgroundColor, + borderRadius: borderRadius, + ), + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: child, + ), + ), + ); + } +} + +class BubblePainter extends CustomPainter { + final Color color; + final double borderRadius; + + BubblePainter({required this.color, required this.borderRadius}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + const arrowWidth = 16.0; + const arrowHeight = 8.0; + final rectWidth = size.width; + final rectHeight = size.height - arrowHeight; // 减去箭头的高度 + + // 创建带圆角的矩形区域 + final rrect = RRect.fromLTRBR( + 0, + 0, + rectWidth, + rectHeight, + Radius.circular(borderRadius), + ); + + // 创建路径 + final path = Path() + ..addRRect(rrect) // 添加圆角矩形 + ..moveTo((rectWidth - arrowWidth) / 2, rectHeight) // 箭头左侧 + ..lineTo(rectWidth / 2, rectHeight + arrowHeight) // 箭头尖端 + ..lineTo((rectWidth + arrowWidth) / 2, rectHeight); // 箭头右侧 + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) { + return false; + } +} diff --git a/lib/components/side_bar/side_bar_logic.dart b/lib/components/side_bar/side_bar_logic.dart index c12dcf1..7eaff83 100644 --- a/lib/components/side_bar/side_bar_logic.dart +++ b/lib/components/side_bar/side_bar_logic.dart @@ -19,17 +19,17 @@ class SideBarLogic extends GetxController { getHitokoto(); getImage(); getInfo(); - getWeather(); + // getWeather(); super.onReady(); } - Future getWeather() async { - var key = Utils().prefUtil.getValue('qweatherKey'); - if (state.getWeather && key != null) { - state.weatherResponse.value = - await Utils().cacheUtil.getCacheList('weather', Api().updateWeather, maxAgeMillis: 15 * 60000) ?? []; - } - } + // Future getWeather() async { + // var key = Utils().prefUtil.getValue('qweatherKey'); + // if (state.getWeather && key != null) { + // state.weatherResponse.value = + // await Utils().cacheUtil.getCacheList('weather', Api().updateWeather, maxAgeMillis: 15 * 60000) ?? []; + // } + // } Future getHitokoto() async { var res = await Utils().cacheUtil.getCacheList('hitokoto', Api().updateHitokoto, maxAgeMillis: 15 * 60000); diff --git a/lib/main.dart b/lib/main.dart index c649f21..f77faaf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,7 @@ import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:get/get.dart'; import 'package:intl/find_locale.dart'; @@ -25,8 +26,8 @@ Future initSystem() async { //初始化视频播放 MediaKit.ensureInitialized(); //地图缓存 - //await FMTCObjectBoxBackend().initialise(); - //await const FMTCStore('mapStore').manage.create(); + await FMTCObjectBoxBackend().initialise(); + await const FMTCStore('mapStore').manage.create(); platFormOption(); } diff --git a/lib/pages/edit/edit_logic.dart b/lib/pages/edit/edit_logic.dart index dbeceb8..8304240 100644 --- a/lib/pages/edit/edit_logic.dart +++ b/lib/pages/edit/edit_logic.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_quill/flutter_quill.dart'; import 'package:get/get.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:latlong2/latlong.dart'; import 'package:lottie/lottie.dart'; import 'package:mood_diary/api/api.dart'; import 'package:mood_diary/common/models/isar/diary.dart'; @@ -71,7 +72,7 @@ class EditLogic extends GetxController with WidgetsBindingObserver { if (Get.arguments == 'new') { state.currentDiary = Diary(); if (Utils().prefUtil.getValue('autoWeather') == true) { - unawaited(getWeather()); + unawaited(getPositionAndWeather()); } } else { //如果是编辑,将日记对象赋值 @@ -372,20 +373,43 @@ class EditLogic extends GetxController with WidgetsBindingObserver { update(['Mood']); } - //获取天气 - Future getWeather() async { + //获取天气,同时获取定位 + Future getPositionAndWeather() async { var key = Utils().prefUtil.getValue('qweatherKey'); - if (key != null) { - state.isProcessing = true; - update(); - var res = await Api().updateWeather(); - if (res != null) { - state.currentDiary.weather = res; - state.isProcessing = false; - Utils().noticeUtil.showToast('获取成功'); - update(['Weather']); - } + if (key == null) return; + + state.isProcessing = true; + update(['Weather']); + + // 获取定位 + var position = await Api().updatePosition(); + if (position == null) { + _handleError('定位失败'); + return; } + + state.currentDiary.position = position; + + // 获取天气 + var weather = await Api().updateWeather( + position: LatLng(double.parse(position[0]), double.parse(position[1])), + ); + + if (weather == null) { + _handleError('天气获取失败'); + return; + } + + state.currentDiary.weather = weather; + state.isProcessing = false; + Utils().noticeUtil.showToast('获取成功'); + update(['Weather']); + } + + void _handleError(String message) { + state.isProcessing = false; + Utils().noticeUtil.showToast(message); + update(['Weather']); } //获取音频名称 diff --git a/lib/pages/edit/edit_view.dart b/lib/pages/edit/edit_view.dart index 7f944df..1ce3217 100644 --- a/lib/pages/edit/edit_view.dart +++ b/lib/pages/edit/edit_view.dart @@ -428,8 +428,8 @@ class EditPage extends StatelessWidget { trailing: state.isProcessing ? const CircularProgressIndicator() : IconButton.filledTonal( - onPressed: () { - logic.getWeather(); + onPressed: () async { + await logic.getPositionAndWeather(); }, icon: const Icon(Icons.location_on), ), diff --git a/lib/pages/map/map_logic.dart b/lib/pages/map/map_logic.dart index ce3370c..78b84fd 100644 --- a/lib/pages/map/map_logic.dart +++ b/lib/pages/map/map_logic.dart @@ -1,7 +1,11 @@ +import 'package:flutter/services.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:geolocator/geolocator.dart'; import 'package:get/get.dart'; import 'package:latlong2/latlong.dart'; +import 'package:mood_diary/pages/diary_details/diary_details_logic.dart'; +import 'package:mood_diary/router/app_routes.dart'; +import 'package:mood_diary/utils/utils.dart'; import 'map_state.dart'; @@ -13,6 +17,7 @@ class MapLogic extends GetxController { @override void onReady() async { state.currentLatLng = await getLocation(); + await getAllItem(); update(); super.onReady(); } @@ -29,4 +34,26 @@ class MapLogic extends GetxController { position ??= await Geolocator.getCurrentPosition(locationSettings: AndroidSettings(forceLocationManager: true)); return LatLng(position.latitude, position.longitude); } + + Future getAllItem() async { + state.diaryMapItemList = await Utils().isarUtil.getAllMapItem(); + } + + Future toCurrentPosition() async { + Utils().noticeUtil.showToast('定位中'); + var currentPosition = await getLocation(); + Utils().logUtil.printInfo(currentPosition.toString()); + Utils().noticeUtil.showToast('定位成功'); + mapController.move(currentPosition, mapController.camera.maxZoom!); + } + + Future toDiaryPage({required int isarId}) async { + await HapticFeedback.mediumImpact(); + var diary = await Utils().isarUtil.getDiaryByID(isarId); + Bind.lazyPut(() => DiaryDetailsLogic(), tag: diary!.id); + await Get.toNamed( + AppRoutes.diaryPage, + arguments: [diary, false], + ); + } } diff --git a/lib/pages/map/map_state.dart b/lib/pages/map/map_state.dart index f657016..983ed2a 100644 --- a/lib/pages/map/map_state.dart +++ b/lib/pages/map/map_state.dart @@ -1,12 +1,13 @@ import 'package:latlong2/latlong.dart'; +import 'package:mood_diary/common/models/map.dart'; import 'package:mood_diary/utils/utils.dart'; class MapState { - late LatLng? currentLatLng; + LatLng? currentLatLng; + + List diaryMapItemList = []; String? tiandituKey = Utils().prefUtil.getValue('tiandituKey'); - MapState() { - currentLatLng = null; - } + MapState(); } diff --git a/lib/pages/map/map_view.dart b/lib/pages/map/map_view.dart index d28abf9..1d2d19e 100644 --- a/lib/pages/map/map_view.dart +++ b/lib/pages/map/map_view.dart @@ -1,7 +1,14 @@ +import 'dart:io'; +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_marker_cluster/flutter_map_marker_cluster.dart'; import 'package:flutter_map_tile_caching/flutter_map_tile_caching.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:get/get.dart'; +import 'package:mood_diary/components/bubble/bubble_view.dart'; +import 'package:mood_diary/utils/utils.dart'; import 'map_logic.dart'; @@ -12,32 +19,86 @@ class MapPage extends StatelessWidget { Widget build(BuildContext context) { final logic = Bind.find(); final state = Bind.find().state; + final colorScheme = Theme.of(context).colorScheme; return Scaffold( + appBar: AppBar( + title: const Text('足迹'), + ), body: GetBuilder( builder: (_) { return state.currentLatLng != null && state.tiandituKey != null ? FlutterMap( mapController: logic.mapController, - options: MapOptions(initialCenter: state.currentLatLng!), + options: + MapOptions(initialCenter: state.currentLatLng!, minZoom: 4.0, initialZoom: 16.0, maxZoom: 18.0), children: [ TileLayer( urlTemplate: - 'http://t6.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${state.tiandituKey}', - tileProvider: - const FMTCStore('mapStore').getTileProvider(), + 'http://t${Random().nextInt(8)}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=img&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${state.tiandituKey}', + tileProvider: const FMTCStore('mapStore').getTileProvider(), ), TileLayer( urlTemplate: - 'http://t6.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${state.tiandituKey}', - tileProvider: - const FMTCStore('mapStore').getTileProvider(), + 'http://t${Random().nextInt(8)}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}&tk=${state.tiandituKey}', + tileProvider: const FMTCStore('mapStore').getTileProvider(), ), + MarkerClusterLayerWidget( + options: MarkerClusterLayerOptions( + markers: List.generate(state.diaryMapItemList.length, (index) { + return Marker( + point: state.diaryMapItemList[index].latLng, + child: GestureDetector( + onTap: () async { + await logic.toDiaryPage(isarId: state.diaryMapItemList[index].id); + }, + child: Bubble( + backgroundColor: colorScheme.tertiary, + borderRadius: 8, + child: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + image: DecorationImage( + image: FileImage(File(Utils().fileUtil.getRealPath( + 'image', state.diaryMapItemList[index].coverImageName))), + fit: BoxFit.cover), + ), + )), + ), + width: 56, + height: 64); + }), + rotate: true, + maxZoom: 18.0, + forceIntegerZoomLevel: true, + showPolygon: false, + builder: (context, markers) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: colorScheme.tertiaryContainer, + border: Border.all(color: colorScheme.tertiary, width: 2)), + child: Center( + child: Text( + markers.length.toString(), + style: TextStyle(color: colorScheme.onTertiaryContainer), + ), + ), + ); + })), ], ) : const Center(child: CircularProgressIndicator()); }, ), + floatingActionButton: FloatingActionButton( + onPressed: () { + logic.toCurrentPosition(); + }, + child: const FaIcon(FontAwesomeIcons.locationCrosshairs), + ), ); } } diff --git a/pubspec.lock b/pubspec.lock index b92086d..0597852 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -22,6 +22,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.11.0" + animated_stack_widget: + dependency: transitive + description: + name: animated_stack_widget + sha256: ce4788dd158768c9d4388354b6fb72600b78e041a37afc4c279c63ecafcb9408 + url: "https://pub.dev" + source: hosted + version: "0.0.4" app_links: dependency: transitive description: @@ -749,6 +757,22 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + flutter_map_marker_cluster: + dependency: "direct main" + description: + name: flutter_map_marker_cluster + sha256: "2c1fb4d7a2105c4bbeb89be215320507f4b71b2036df4341fab9d2aa677d3ae9" + url: "https://pub.dev" + source: hosted + version: "1.4.0" + flutter_map_marker_popup: + dependency: transitive + description: + name: flutter_map_marker_popup + sha256: a7540538114b5d1627ab67b498273d66bc36090385412ae49ef215af4a2861c5 + url: "https://pub.dev" + source: hosted + version: "7.0.0" flutter_map_tile_caching: dependency: "direct main" description: @@ -1353,6 +1377,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.16.8" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" objectbox: dependency: transitive description: @@ -1577,6 +1609,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + provider: + dependency: transitive + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" pub_semver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 894678f..fbd8088 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -83,6 +83,7 @@ dependencies: fc_native_video_thumbnail: 0.16.1 flutter_map: 7.0.2 flutter_map_tile_caching: 9.1.3 + flutter_map_marker_cluster: 1.4.0 latlong2: 0.9.1 shelf: 1.4.2 shelf_multipart: 2.0.0