feat(map): Add map support for viewing and managing diary entry locations

Closes #13
This commit is contained in:
ZhuJHua
2024-11-07 02:05:57 +08:00
parent f8455c7c88
commit bef23b8426
14 changed files with 416 additions and 49 deletions

3
devtools_options.yaml Normal file
View File

@@ -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:

View File

@@ -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<List<String>?> updateWeather() async {
Future<List<String>?> 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<String>('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<String, dynamic>);
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<String, dynamic>);
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<List<String>?> 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<String>('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<String, dynamic>);
if (weather.now != null) {
return [
weather.now!.icon!,
weather.now!.temp!,
weather.now!.text!,
];
} else {
return null;
}
return null;
}
Future<GithubRelease?> getGithubRelease() async {

106
lib/common/models/geo.dart Normal file
View File

@@ -0,0 +1,106 @@
class GeoResponse {
String? code;
List<Location>? location;
Refer? refer;
GeoResponse({this.code, this.location, this.refer});
GeoResponse.fromJson(Map<String, dynamic> 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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
data["code"] = code;
data["location"] = location?.map((e) => e.toJson()).toList();
data["refer"] = refer?.toJson();
return data;
}
}
class Refer {
List<String>? sources;
List<String>? license;
Refer({this.sources, this.license});
Refer.fromJson(Map<String, dynamic> json) {
sources = json["sources"] == null ? null : List<String>.from(json["sources"]);
license = json["license"] == null ? null : List<String>.from(json["license"]);
}
Map<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
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<String, dynamic> 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<String, dynamic> toJson() {
final Map<String, dynamic> data = <String, dynamic>{};
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;
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}

View File

@@ -19,17 +19,17 @@ class SideBarLogic extends GetxController {
getHitokoto();
getImage();
getInfo();
getWeather();
// getWeather();
super.onReady();
}
Future<void> getWeather() async {
var key = Utils().prefUtil.getValue<String>('qweatherKey');
if (state.getWeather && key != null) {
state.weatherResponse.value =
await Utils().cacheUtil.getCacheList('weather', Api().updateWeather, maxAgeMillis: 15 * 60000) ?? [];
}
}
// Future<void> getWeather() async {
// var key = Utils().prefUtil.getValue<String>('qweatherKey');
// if (state.getWeather && key != null) {
// state.weatherResponse.value =
// await Utils().cacheUtil.getCacheList('weather', Api().updateWeather, maxAgeMillis: 15 * 60000) ?? [];
// }
// }
Future<void> getHitokoto() async {
var res = await Utils().cacheUtil.getCacheList('hitokoto', Api().updateHitokoto, maxAgeMillis: 15 * 60000);

View File

@@ -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<void> initSystem() async {
//初始化视频播放
MediaKit.ensureInitialized();
//地图缓存
//await FMTCObjectBoxBackend().initialise();
//await const FMTCStore('mapStore').manage.create();
await FMTCObjectBoxBackend().initialise();
await const FMTCStore('mapStore').manage.create();
platFormOption();
}

View File

@@ -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<bool>('autoWeather') == true) {
unawaited(getWeather());
unawaited(getPositionAndWeather());
}
} else {
//如果是编辑,将日记对象赋值
@@ -372,20 +373,43 @@ class EditLogic extends GetxController with WidgetsBindingObserver {
update(['Mood']);
}
//获取天气
Future<void> getWeather() async {
//获取天气,同时获取定位
Future<void> getPositionAndWeather() async {
var key = Utils().prefUtil.getValue<String>('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']);
}
//获取音频名称

View File

@@ -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),
),

View File

@@ -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<void> getAllItem() async {
state.diaryMapItemList = await Utils().isarUtil.getAllMapItem();
}
Future<void> toCurrentPosition() async {
Utils().noticeUtil.showToast('定位中');
var currentPosition = await getLocation();
Utils().logUtil.printInfo(currentPosition.toString());
Utils().noticeUtil.showToast('定位成功');
mapController.move(currentPosition, mapController.camera.maxZoom!);
}
Future<void> 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],
);
}
}

View File

@@ -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<DiaryMapItem> diaryMapItemList = [];
String? tiandituKey = Utils().prefUtil.getValue<String>('tiandituKey');
MapState() {
currentLatLng = null;
}
MapState();
}

View File

@@ -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<MapLogic>();
final state = Bind.find<MapLogic>().state;
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('足迹'),
),
body: GetBuilder<MapLogic>(
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),
),
);
}
}

View File

@@ -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:

View File

@@ -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