76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
263 lines
8.5 KiB
Dart
263 lines
8.5 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../api/oa_client.dart';
|
|
import '../device/device_bridge.dart';
|
|
import '../theme.dart';
|
|
import '../widgets/tencent_map_thumb.dart';
|
|
import '../widgets/wecom.dart';
|
|
|
|
class GeoPlace {
|
|
GeoPlace({required this.lat, required this.lng, required this.title, required this.address, this.distance});
|
|
final double lat;
|
|
final double lng;
|
|
final String title;
|
|
final String address;
|
|
final num? distance;
|
|
|
|
Map<String, dynamic> toMeta() => {
|
|
'lat': lat,
|
|
'lng': lng,
|
|
'title': title,
|
|
'address': address,
|
|
};
|
|
}
|
|
|
|
class LocationPickPage extends StatefulWidget {
|
|
const LocationPickPage({super.key, required this.api});
|
|
final OaClient api;
|
|
|
|
@override
|
|
State<LocationPickPage> createState() => _LocationPickPageState();
|
|
}
|
|
|
|
class _LocationPickPageState extends State<LocationPickPage> {
|
|
double? _lat;
|
|
double? _lng;
|
|
GeoPlace? _picked;
|
|
List<GeoPlace> _pois = [];
|
|
bool _loading = true;
|
|
String _err = '';
|
|
String _q = '';
|
|
Timer? _searchDebounce;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_locate();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchDebounce?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _locate() async {
|
|
setState(() {
|
|
_loading = true;
|
|
_err = '';
|
|
});
|
|
try {
|
|
Map<String, double>? loc;
|
|
try {
|
|
loc = await DeviceBridge.getLocation();
|
|
} catch (_) {}
|
|
final lat = loc?['lat'];
|
|
final lng = loc?['lng'];
|
|
if (lat == null || lng == null) {
|
|
throw Exception('没有拿到定位,请允许位置权限并打开系统定位');
|
|
}
|
|
await _loadAround(lat, lng);
|
|
} catch (e) {
|
|
if (mounted) setState(() { _loading = false; _err = '$e'; });
|
|
}
|
|
}
|
|
|
|
Future<void> _loadAround(double lat, double lng) async {
|
|
final data = await widget.api.post('/office/geo/reverse', {'lat': lat, 'lng': lng});
|
|
if (!mounted) return;
|
|
final map = data is Map ? Map<String, dynamic>.from(data) : <String, dynamic>{};
|
|
final here = GeoPlace(
|
|
lat: lat,
|
|
lng: lng,
|
|
title: '${map['title'] ?? '当前位置'}',
|
|
address: '${map['address'] ?? ''}',
|
|
);
|
|
final pois = <GeoPlace>[here];
|
|
final raw = map['pois'];
|
|
if (raw is List) {
|
|
for (final e in raw) {
|
|
if (e is! Map) continue;
|
|
final plat = (e['lat'] as num?)?.toDouble();
|
|
final plng = (e['lng'] as num?)?.toDouble();
|
|
if (plat == null || plng == null) continue;
|
|
pois.add(GeoPlace(
|
|
lat: plat,
|
|
lng: plng,
|
|
title: '${e['title'] ?? e['address'] ?? '地点'}',
|
|
address: '${e['address'] ?? ''}',
|
|
distance: e['distance'] as num?,
|
|
));
|
|
}
|
|
}
|
|
setState(() {
|
|
_lat = lat;
|
|
_lng = lng;
|
|
_picked = here;
|
|
_pois = pois;
|
|
_loading = false;
|
|
_err = '';
|
|
});
|
|
}
|
|
|
|
void _onSearch(String q) {
|
|
_q = q.trim();
|
|
_searchDebounce?.cancel();
|
|
if (_q.isEmpty) {
|
|
if (_lat != null && _lng != null) _loadAround(_lat!, _lng!);
|
|
return;
|
|
}
|
|
_searchDebounce = Timer(const Duration(milliseconds: 350), () async {
|
|
try {
|
|
final data = await widget.api.get('/office/geo/suggest', query: {
|
|
'keyword': _q,
|
|
if (_lat != null) 'lat': '$_lat',
|
|
if (_lng != null) 'lng': '$_lng',
|
|
});
|
|
if (!mounted) return;
|
|
final list = data is List ? data : (data is Map ? (data['items'] ?? data['data'] ?? []) : []);
|
|
final pois = <GeoPlace>[];
|
|
for (final e in (list is List ? list : <dynamic>[])) {
|
|
if (e is! Map) continue;
|
|
final plat = (e['lat'] as num?)?.toDouble();
|
|
final plng = (e['lng'] as num?)?.toDouble();
|
|
if (plat == null || plng == null) continue;
|
|
pois.add(GeoPlace(
|
|
lat: plat,
|
|
lng: plng,
|
|
title: '${e['title'] ?? '地点'}',
|
|
address: '${e['address'] ?? ''}',
|
|
distance: e['distance'] as num?,
|
|
));
|
|
}
|
|
setState(() {
|
|
_pois = pois;
|
|
if (pois.isNotEmpty) _picked = pois.first;
|
|
});
|
|
} catch (_) {}
|
|
});
|
|
}
|
|
|
|
String _dist(num? d) {
|
|
if (d == null) return '';
|
|
if (d < 1) return '当前位置';
|
|
if (d < 1000) return '${d.round()}m';
|
|
return '${(d / 1000).toStringAsFixed(1)}km';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final picked = _picked;
|
|
return Scaffold(
|
|
backgroundColor: Colors.white,
|
|
appBar: AppBar(
|
|
leading: TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')),
|
|
title: const Text('发送位置'),
|
|
actions: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8, top: 8, bottom: 8),
|
|
child: FilledButton(
|
|
style: FilledButton.styleFrom(backgroundColor: kWeGreen, minimumSize: const Size(64, 32)),
|
|
onPressed: picked == null ? null : () => Navigator.pop(context, picked),
|
|
child: const Text('发送'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
SizedBox(
|
|
height: 220,
|
|
width: double.infinity,
|
|
child: Stack(
|
|
children: [
|
|
if (picked != null)
|
|
Positioned.fill(
|
|
child: TencentMapThumb(api: widget.api, lat: picked.lat, lng: picked.lng, height: 220),
|
|
)
|
|
else
|
|
const ColoredBox(color: Color(0xFFE8F5E9), child: Center(child: CircularProgressIndicator())),
|
|
Positioned(
|
|
left: 12,
|
|
bottom: 12,
|
|
child: Material(
|
|
color: Colors.white,
|
|
shape: const CircleBorder(),
|
|
elevation: 2,
|
|
child: IconButton(
|
|
onPressed: _locate,
|
|
icon: const Icon(Icons.my_location, color: kBind),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
|
child: WxSearchBar(hint: '搜索地点', onChanged: _onSearch),
|
|
),
|
|
if (_err.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Text(_err, style: const TextStyle(color: kDanger, fontSize: 13)),
|
|
),
|
|
Expanded(
|
|
child: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: ListView.builder(
|
|
itemCount: _pois.length,
|
|
itemBuilder: (_, i) {
|
|
final p = _pois[i];
|
|
final on = picked?.lat == p.lat && picked?.lng == p.lng && picked?.title == p.title;
|
|
return InkWell(
|
|
onTap: () => setState(() => _picked = p),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Container(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: kLine, width: 0.5))),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(p.title, style: const TextStyle(fontSize: 16, color: kInk)),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
[_dist(p.distance), p.address].where((e) => e.isNotEmpty).join(' | '),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 12, color: kMute),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
if (on) const Padding(padding: EdgeInsets.only(left: 8), child: Icon(Icons.check, color: kWeGreen)),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|