From 774403785c3fc33c24ee4f956d58da3f90057ec4 Mon Sep 17 00:00:00 2001 From: andyesfly <35127884@qq.com> Date: Sat, 17 Oct 2026 10:10:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=8B=98=E5=AF=9F?= =?UTF-8?q?=E6=98=8E=E7=BB=86=E7=AE=A1=E7=90=86=E4=B8=8E=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ios/Runner/Info.plist | 6 + lib/app/router.dart | 7 +- lib/core/network/api_client.dart | 55 + lib/features/auth/data/auth_api.dart | 8 + .../presentation/change_password_page.dart | 208 ++++ .../presentation/notice_center_page.dart | 14 +- lib/features/order/data/order_api.dart | 13 + lib/features/order/data/order_models.dart | 173 ++++ .../order/presentation/order_detail_page.dart | 944 +++++++++++++++++- .../profile/presentation/profile_tab.dart | 28 +- pubspec.lock | 114 ++- pubspec.yaml | 1 + 12 files changed, 1553 insertions(+), 18 deletions(-) create mode 100644 lib/features/auth/presentation/change_password_page.dart diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 52ab127..8f03d66 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -4,6 +4,12 @@ CADisableMinimumFrameDurationOnPhone + NSCameraUsageDescription + 用于拍摄勘察现场照片 + NSPhotoLibraryUsageDescription + 用于从相册选择勘察现场照片 + NSMicrophoneUsageDescription + 用于拍摄勘察现场照片 CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName diff --git a/lib/app/router.dart b/lib/app/router.dart index d034b84..2a06d6b 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import '../core/app_keys.dart'; import '../features/auth/application/session_controller.dart'; +import '../features/auth/presentation/change_password_page.dart'; import '../features/auth/presentation/login_page.dart'; import '../features/auth/presentation/register_page.dart'; import '../features/notice/presentation/notice_center_page.dart'; @@ -38,9 +39,11 @@ final routerProvider = Provider((ref) { }, routes: [ GoRoute(path: '/login', builder: (context, state) => const LoginPage()), + GoRoute(path: '/register', builder: (context, state) => const RegisterPage()), + // 修改登录密码:「我的」页入口 GoRoute( - path: '/register', - builder: (context, state) => const RegisterPage(), + path: '/change-password', + builder: (context, state) => const ChangePasswordPage(), ), GoRoute(path: '/tabs', builder: (context, state) => const TabsPage()), diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 18bce5f..237c707 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -6,6 +6,31 @@ import 'package:dio/dio.dart'; import '../storage/token_storage.dart'; import 'api_response.dart'; +/// 图片上传结果,与后端 `POST /api/upload/image` 的 `data` 对齐。 +class UploadResult { + const UploadResult({ + required this.key, + required this.url, + required this.name, + required this.size, + }); + + /// 对象存储 key,持久化到业务表。 + final String key; + + /// 预签名访问 URL,用于即时预览。 + final String url; + final String name; + final int size; + + factory UploadResult.fromJson(Map json) => UploadResult( + key: json['key'] as String? ?? '', + url: json['url'] as String? ?? '', + name: json['name'] as String? ?? '', + size: (json['size'] as num?)?.toInt() ?? 0, + ); +} + /// 统一请求封装,等价于 uni-app 版 `utils/request.ts`: /// - 自动注入 `Authorization: Bearer ` /// - 解析 `{ code, message, data }`,非 200 也照常返回给调用方判断 @@ -64,6 +89,36 @@ class ApiClient { bool needAuth = true, }) => _send('PUT', url, body: body, parse: parse, needAuth: needAuth); + /// 上传现场照片到对象存储(multipart/form-data,字段名 `file`)。 + /// 走电工通道 `POST /api/worker-app/upload/image`(电工 token 无法访问管理端 `/api/upload`)。 + /// 依赖对象存储:失败直接抛异常,由调用方阻断保存并提示用户。 + Future uploadImage(String filePath) async { + try { + final form = FormData.fromMap({ + 'file': await MultipartFile.fromFile(filePath), + }); + final res = await _dio.post( + '/worker-app/upload/image', + data: form, + options: Options(headers: _authHeader()), + ); + final json = _asMap(res.data); + final code = (json['code'] as num?)?.toInt(); + final data = json['data']; + if (code == 200 && data is Map) { + return UploadResult.fromJson(data.cast()); + } + throw ApiException( + (json['message'] as String?) ?? '图片上传失败', + code: code, + ); + } on ApiException { + rethrow; + } catch (_) { + throw const ApiException('图片上传失败:网络异常'); + } + } + void dispose() => _unauthorizedController.close(); // ---- 内部实现 ---- diff --git a/lib/features/auth/data/auth_api.dart b/lib/features/auth/data/auth_api.dart index 1b25cdd..a47937e 100644 --- a/lib/features/auth/data/auth_api.dart +++ b/lib/features/auth/data/auth_api.dart @@ -55,4 +55,12 @@ class AuthApi { }, parse: (d) => WorkerInfo.fromJson((d as Map).cast()), ); + + /// 修改登录密码。后端 `/worker-app/profile` 收到 `password` 即更新密码哈希。 + Future> changePassword(String password) => + _client.put( + '/worker-app/profile', + body: {'password': password}, + parse: (d) => WorkerInfo.fromJson((d as Map).cast()), + ); } diff --git a/lib/features/auth/presentation/change_password_page.dart b/lib/features/auth/presentation/change_password_page.dart new file mode 100644 index 0000000..95a3fb6 --- /dev/null +++ b/lib/features/auth/presentation/change_password_page.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../core/network/api_response.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_dimens.dart'; +import '../../../core/theme/app_typography.dart'; +import '../../../shared/widgets/app_feedback.dart'; +import '../../../shared/widgets/x_card.dart'; +import '../../../shared/widgets/x_common.dart'; +import '../../../shared/widgets/x_top_bar.dart'; +import '../application/session_controller.dart'; + +/// 修改登录密码页。 +/// +/// 后端 `/worker-app/profile` 传 `password` 即更新密码哈希, +/// 因此本页只校验新密码的长度与两次输入一致,不要求原密码。 +class ChangePasswordPage extends ConsumerStatefulWidget { + const ChangePasswordPage({super.key}); + + @override + ConsumerState createState() => _ChangePasswordPageState(); +} + +class _ChangePasswordPageState extends ConsumerState { + final _password = TextEditingController(); + final _confirm = TextEditingController(); + + bool _loading = false; + String _error = ''; + + @override + void initState() { + super.initState(); + _password.addListener(_onChanged); + _confirm.addListener(_onChanged); + } + + @override + void dispose() { + _password.dispose(); + _confirm.dispose(); + super.dispose(); + } + + void _onChanged() => setState(() {}); + + bool get _canSubmit => + _password.text.length >= 6 && _password.text == _confirm.text; + + Future _handleSubmit() async { + if (!_canSubmit) { + setState(() => _error = '新密码至少 6 位,且两次输入需一致'); + return; + } + + setState(() { + _loading = true; + _error = ''; + }); + try { + final res = await ref + .read(authApiProvider) + .changePassword(_password.text); + if (!mounted) return; + if (!res.isOk) { + setState(() => _error = res.message.isEmpty ? '修改失败' : res.message); + return; + } + showAppToast('密码修改成功', success: true); + context.pop(); + } on ApiException catch (e) { + if (mounted) setState(() => _error = e.message); + } catch (_) { + if (mounted) setState(() => _error = '网络异常,请稍后重试'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: XPageShell( + topBar: XTopBar.nav(title: '修改密码', onBack: () => context.pop()), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + XCard( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Field( + label: '新密码', + hint: '至少 6 位', + controller: _password, + required: true, + ), + const SizedBox(height: 18), + _Field( + label: '确认新密码', + hint: '请再次输入新密码', + controller: _confirm, + required: true, + ), + ], + ), + ), + const SizedBox(height: 20), + XPrimaryButton( + label: '确认修改', + height: 56, + radius: AppRadius.xl, + loading: _loading, + onPressed: _canSubmit ? _handleSubmit : null, + shadows: AppShadows.primaryButton, + ), + if (_error.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 14), + child: Text( + _error, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 13, color: AppColors.error), + ), + ), + const SizedBox(height: 16), + const Text( + '如忘记密码,可联系平台管理员重置为手机号后 6 位。', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: AppColors.textMuted), + ), + ], + ), + ), + ), + ); + } +} + +/// 表单输入项,与注册页保持同一套视觉语言。 +class _Field extends StatelessWidget { + const _Field({ + required this.label, + required this.hint, + required this.controller, + this.required = false, + }); + + final String label; + final String hint; + final TextEditingController controller; + final bool required; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: AppTypography.bodySm.copyWith( + color: AppColors.onSurfaceVariant, + ), + ), + if (required) + const Text( + ' *', + style: TextStyle(fontSize: 13, color: AppColors.error), + ), + ], + ), + const SizedBox(height: 8), + Container( + height: 48, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: AppColors.surfaceBright, + border: Border.all(color: AppColors.borderSoft), + borderRadius: BorderRadius.circular(AppRadius.lg), + ), + child: TextField( + controller: controller, + obscureText: true, + style: const TextStyle(fontSize: 15, color: AppColors.onSurface), + cursorColor: AppColors.primary, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + counterText: '', + hintText: hint, + hintStyle: const TextStyle( + fontSize: 15, + color: AppColors.textPlaceholder, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/notice/presentation/notice_center_page.dart b/lib/features/notice/presentation/notice_center_page.dart index 1ec8088..3d5cb5a 100644 --- a/lib/features/notice/presentation/notice_center_page.dart +++ b/lib/features/notice/presentation/notice_center_page.dart @@ -108,6 +108,16 @@ class _NoticeCenterPageState extends ConsumerState { Future _loadMore() => _load(); + /// 点击消息:先标记已读;勘察指派消息(`bizType == "survey_order"`) + /// 带 `bizId` 时跳转到对应勘察订单详情,其余保持仅标记已读。 + Future _onNoticeTap(Notice notice) async { + if (!notice.isRead) await _markRead(notice); + final bizId = notice.bizId; + if (notice.bizType == 'survey_order' && bizId != null && mounted) { + context.push('/order/survey/$bizId'); + } + } + Future _markRead(Notice notice) async { if (notice.isRead) return; try { @@ -231,7 +241,7 @@ class _NoticeCenterPageState extends ConsumerState { final notice = _items[index]; return _NoticeTile( notice: notice, - onTap: notice.isRead ? null : () => _markRead(notice), + onTap: () => _onNoticeTap(notice), ); }, ), @@ -281,7 +291,7 @@ class _NoticeCenterPageState extends ConsumerState { } } -/// 单条消息卡片:未读有蓝底高亮 + 红点,点击标记已读。 +/// 单条消息卡片:未读有蓝底高亮 + 红点,点击标记已读并按业务类型跳转。 class _NoticeTile extends StatelessWidget { const _NoticeTile({required this.notice, this.onTap}); diff --git a/lib/features/order/data/order_api.dart b/lib/features/order/data/order_api.dart index 243180f..d88e748 100644 --- a/lib/features/order/data/order_api.dart +++ b/lib/features/order/data/order_api.dart @@ -33,6 +33,19 @@ class OrderApi { Future> grab(String source, int id) => _client.post('/worker-app/orders/$source/$id/grab'); + /// 保存勘察明细(全量替换)。仅本人已接单且状态为「现场勘察中」的勘察单可写, + /// 成功返回服务端最新明细(含预签名 `photoUrls`)。 + Future>> saveSurveyDetails( + int id, + List details, + ) => _client.put>( + '/worker-app/orders/survey/$id/details', + body: {'details': details.map((d) => d.toPayload()).toList()}, + parse: (d) => (d as List? ?? const []) + .map((e) => SurveyDetail.fromJson((e as Map).cast())) + .toList(), + ); + /// 完成订单。非本人订单后端返回 403。 Future> complete(String source, int id) => _client.put('/worker-app/orders/$source/$id/complete'); diff --git a/lib/features/order/data/order_models.dart b/lib/features/order/data/order_models.dart index 274fc2b..96129d2 100644 --- a/lib/features/order/data/order_models.dart +++ b/lib/features/order/data/order_models.dart @@ -22,6 +22,8 @@ class Order { required this.laborFee, required this.materialFee, required this.estimatedDuration, + this.details = const [], + this.topology, }); /// `repair-12` @@ -52,6 +54,18 @@ class Order { final double materialFee; final String estimatedDuration; + /// 勘察明细(仅 `source == "survey"` 的详情接口返回)。 + final List details; + + /// 客户空间拓扑(仅 `source == "survey"` 的详情接口返回)。 + final CustomerTopology? topology; + + /// 是否为现场勘察单。 + bool get isSurvey => source == 'survey'; + + /// 是否处于「现场勘察中(1)」,仅该状态可维护勘察明细。 + bool get surveyEditable => source == 'survey' && status == 1; + /// 来源中文名,用于收益明细副标题等场景。 String get sourceName => switch (source) { 'repair' => '家庭电路维修', @@ -109,5 +123,164 @@ class Order { laborFee: toDoubleOr(json['laborFee'], 0), materialFee: toDoubleOr(json['materialFee'], 0), estimatedDuration: json['estimatedDuration'] as String? ?? '', + details: (json['details'] as List? ?? const []) + .map((e) => SurveyDetail.fromJson((e as Map).cast())) + .toList(), + topology: json['topology'] is Map + ? CustomerTopology.fromJson( + (json['topology'] as Map).cast(), + ) + : null, ); } + +/// 勘察明细:客户空间拓扑中的「建筑 + 楼层 + 房间(选填) + 现场照片 + 备注」。 +class SurveyDetail { + const SurveyDetail({ + this.id = 0, + required this.buildingId, + required this.buildingName, + required this.floorNo, + this.roomId, + this.roomName = '', + this.photos = const [], + this.photoUrls = const [], + this.remark = '', + }); + + /// 后端主键,新增行传 0(服务端忽略)。 + final int id; + final int buildingId; + final String buildingName; + + /// 楼层:地上 `"1"`、地下 `"-1"`。 + final String floorNo; + + final int? roomId; + final String roomName; + + /// 对象存储 key,提交时传数组。 + final List photos; + + /// 预签名访问 URL,仅用于预览。 + final List photoUrls; + final String remark; + + /// 楼层展示文案:正数 `N层`、负数 `负N层`,无值返回空串。 + String get floorLabel { + final n = int.tryParse(floorNo); + if (n == null || n == 0) return ''; + return n > 0 ? '$n层' : '负${-n}层'; + } + + factory SurveyDetail.fromJson(Map json) => SurveyDetail( + id: toIntOr(json['id'], 0), + buildingId: toIntOr(json['buildingId'], 0), + buildingName: json['buildingName'] as String? ?? '', + floorNo: json['floorNo']?.toString() ?? '', + roomId: toIntOrNull(json['roomId']), + roomName: json['roomName'] as String? ?? '', + photos: + (json['photos'] as List?)?.map((e) => e.toString()).toList() ?? + const [], + photoUrls: + (json['photoUrls'] as List?)?.map((e) => e.toString()).toList() ?? + const [], + remark: json['remark'] as String? ?? '', + ); + + /// 提交给后端的字段(全量替换数组的元素)。 + Map toPayload() => { + 'buildingId': buildingId, + 'buildingName': buildingName, + 'floorNo': floorNo, + 'roomId': roomId, + 'roomName': roomName, + 'photos': photos, + 'remark': remark, + }; +} + +/// 拓扑建筑:`floorCount` 地上层数、`floorDownCount` 地下层数。 +class TopologyBuilding { + const TopologyBuilding({ + required this.id, + required this.buildingName, + required this.floorCount, + required this.floorDownCount, + }); + + final int id; + final String buildingName; + final int floorCount; + final int floorDownCount; + + factory TopologyBuilding.fromJson(Map json) => + TopologyBuilding( + id: toIntOr(json['id'], 0), + buildingName: json['buildingName'] as String? ?? '', + floorCount: toIntOr(json['floorCount'], 0), + floorDownCount: toIntOr(json['floorDownCount'], 0), + ); + + /// 生成楼层选项:value 地上 `"1"`/地下 `"-1"`,label `"1层"`/`"负1层"`。 + List<({String value, String label})> get floorOptions => [ + for (var i = 1; i <= floorCount; i++) (value: '$i', label: '$i层'), + for (var i = 1; i <= floorDownCount; i++) (value: '-$i', label: '负$i层'), + ]; +} + +/// 拓扑房间:按 `buildingId` + `floorNo` 归属。 +class TopologyRoom { + const TopologyRoom({ + required this.id, + required this.roomName, + required this.buildingId, + required this.floorNo, + }); + + final int id; + final String roomName; + final int buildingId; + final String floorNo; + + factory TopologyRoom.fromJson(Map json) => TopologyRoom( + id: toIntOr(json['id'], 0), + roomName: json['roomName'] as String? ?? '', + buildingId: toIntOr(json['buildingId'], 0), + floorNo: json['floorNo']?.toString() ?? '', + ); +} + +/// 客户空间拓扑:建筑 + 房间。 +class CustomerTopology { + const CustomerTopology({this.buildings = const [], this.rooms = const []}); + + final List buildings; + final List rooms; + + factory CustomerTopology.fromJson(Map json) => + CustomerTopology( + buildings: (json['buildings'] as List? ?? const []) + .map( + (e) => TopologyBuilding.fromJson((e as Map).cast()), + ) + .toList(), + rooms: (json['rooms'] as List? ?? const []) + .map((e) => TopologyRoom.fromJson((e as Map).cast())) + .toList(), + ); + + /// 按建筑 + 楼层过滤房间,用于明细编辑时的房间下拉。 + List roomsOf(int buildingId, String floorNo) => rooms + .where((r) => r.buildingId == buildingId && r.floorNo == floorNo) + .toList(); + + /// 按 id 查建筑。 + TopologyBuilding? buildingOf(int id) { + for (final b in buildings) { + if (b.id == id) return b; + } + return null; + } +} diff --git a/lib/features/order/presentation/order_detail_page.dart b/lib/features/order/presentation/order_detail_page.dart index 1a1b9e3..d2eca75 100644 --- a/lib/features/order/presentation/order_detail_page.dart +++ b/lib/features/order/presentation/order_detail_page.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../../core/network/api_response.dart'; +import '../../../core/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_typography.dart'; @@ -98,7 +101,10 @@ class OrderDetailPage extends ConsumerWidget { const SizedBox(height: 12), _CustomerCard(order: order), const SizedBox(height: 16), - _PhotosSection(photos: order.photos), + if (order.isSurvey) + _SurveyDetailsSection(order: order) + else + _PhotosSection(photos: order.photos), const SizedBox(height: 16), _FinancialCard(order: order), const SizedBox(height: 16), @@ -737,3 +743,939 @@ class _DotPatternPainter extends CustomPainter { @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } + +/// 打开单张照片大图预览。 +void _previewPhoto(BuildContext context, String url) { + showDialog( + context: context, + barrierColor: Colors.black, + builder: (_) => Dialog( + backgroundColor: Colors.transparent, + insetPadding: EdgeInsets.zero, + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Center(child: Image.network(url, fit: BoxFit.contain)), + ), + ), + ); +} + +/// 勘察明细区块:展示客户空间拓扑与明细列表;「现场勘察中」时可增删改并保存。 +class _SurveyDetailsSection extends ConsumerStatefulWidget { + const _SurveyDetailsSection({required this.order}); + + final Order order; + + @override + ConsumerState<_SurveyDetailsSection> createState() => + _SurveyDetailsSectionState(); +} + +class _SurveyDetailsSectionState extends ConsumerState<_SurveyDetailsSection> { + List _rows = const []; + bool _dirty = false; + bool _saving = false; + + Order get _order => widget.order; + bool get _editable => _order.surveyEditable; + + @override + void initState() { + super.initState(); + _rows = List.of(_order.details); + } + + @override + void didUpdateWidget(covariant _SurveyDetailsSection oldWidget) { + super.didUpdateWidget(oldWidget); + // 详情重新加载后同步服务端明细;本地有未保存改动时不覆盖。 + if (!_dirty && !identical(oldWidget.order.details, _order.details)) { + _rows = List.of(_order.details); + } + } + + Future _editRow([int? index]) async { + final topology = _order.topology; + if (topology == null || topology.buildings.isEmpty) { + showAppToast('该客户暂无空间拓扑,无法维护勘察明细'); + return; + } + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppColors.surfaceContainerLowest, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)), + ), + builder: (_) => _DetailEditorSheet( + topology: topology, + initial: index == null ? null : _rows[index], + ), + ); + if (result == null || !mounted) return; + setState(() { + if (index == null) { + _rows = [..._rows, result]; + } else { + _rows = [..._rows]..[index] = result; + } + _dirty = true; + }); + } + + Future _confirmRemove(int index) async { + final ok = await showAppConfirm( + title: '删除明细', + content: '确定删除该条勘察明细吗?', + destructive: true, + ); + if (!ok || !mounted) return; + setState(() { + _rows = [..._rows]..removeAt(index); + _dirty = true; + }); + } + + Future _save() async { + if (_saving) return; + // 建筑、楼层为必填要素,缺失的行提交前过滤。 + final cleaned = _rows + .where((d) => d.buildingId != 0 && d.floorNo.isNotEmpty) + .toList(); + if (cleaned.length != _rows.length) { + showAppToast('缺少建筑或楼层的明细行已忽略'); + } + setState(() => _saving = true); + try { + final res = await ref + .read(orderApiProvider) + .saveSurveyDetails(_order.intId, cleaned); + if (!mounted) return; + if (res.isOk && res.data != null) { + setState(() { + _rows = List.of(res.data!); + _dirty = false; + }); + showAppToast('勘察明细已保存', success: true); + } else { + showAppToast(res.message.isEmpty ? '保存失败' : res.message); + } + } on ApiException catch (e) { + if (mounted) showAppToast(e.message); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final topology = _order.topology; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _header(), + if (topology != null && topology.buildings.isNotEmpty) ...[ + _TopologyCard(topology: topology), + const SizedBox(height: 12), + ], + if (_rows.isEmpty) + XCard( + padding: const EdgeInsets.symmetric(vertical: 28), + child: const XPlaceholder( + text: '暂无勘察明细', + padding: EdgeInsets.zero, + ), + ) + else + for (var i = 0; i < _rows.length; i++) ...[ + if (i > 0) const SizedBox(height: 12), + _DetailRowCard( + detail: _rows[i], + editable: _editable, + onEdit: () => _editRow(i), + onDelete: () => _confirmRemove(i), + ), + ], + if (_editable) ...[ + const SizedBox(height: 16), + XPrimaryButton( + label: '保存勘察明细', + loading: _saving, + onPressed: _dirty ? _save : null, + ), + ], + ], + ); + } + + Widget _header() { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Text( + '勘察明细', + style: AppTypography.bodySm.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + letterSpacing: 0.26, + ), + ), + const Spacer(), + if (_editable) + GestureDetector( + onTap: () => _editRow(), + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.accentSubtle, + borderRadius: BorderRadius.circular(AppRadius.regular), + ), + child: Text( + '+ 新增明细', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ), + ), + ], + ), + ); + } +} + +/// 客户空间拓扑摘要:列出各建筑及其地上/地下层数。 +class _TopologyCard extends StatelessWidget { + const _TopologyCard({required this.topology}); + + final CustomerTopology topology; + + @override + Widget build(BuildContext context) { + return XCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + '客户空间', + style: AppTypography.bodySm.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.textMuted, + letterSpacing: 0.26, + ), + ), + ), + for (var i = 0; i < topology.buildings.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + Row( + children: [ + const XIcon('location', size: 18, color: AppColors.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + topology.buildings[i].buildingName, + style: AppTypography.bodyMd.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.onSurface, + ), + ), + ), + Text( + _floorSummary(topology.buildings[i]), + style: AppTypography.labelCaps.copyWith( + color: AppColors.textMuted, + ), + ), + ], + ), + ], + ], + ), + ); + } + + static String _floorSummary(TopologyBuilding b) { + final parts = []; + if (b.floorCount > 0) parts.add('地上 ${b.floorCount} 层'); + if (b.floorDownCount > 0) parts.add('地下 ${b.floorDownCount} 层'); + return parts.isEmpty ? '暂无楼层' : parts.join(' · '); + } +} + +/// 单条勘察明细的只读卡片。 +class _DetailRowCard extends StatelessWidget { + const _DetailRowCard({ + required this.detail, + required this.editable, + required this.onEdit, + required this.onDelete, + }); + + final SurveyDetail detail; + final bool editable; + final VoidCallback onEdit; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final building = detail.buildingName.isEmpty + ? '未知建筑' + : detail.buildingName; + final title = detail.floorLabel.isEmpty + ? building + : '$building · ${detail.floorLabel}'; + + return XCard( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + title, + style: AppTypography.bodyLg.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.onSurface, + ), + ), + ), + if (editable) ...[ + const SizedBox(width: 8), + _TextAction( + label: '编辑', + color: AppColors.primary, + onTap: onEdit, + ), + const SizedBox(width: 14), + _TextAction( + label: '删除', + color: AppColors.statusError, + onTap: onDelete, + ), + ], + ], + ), + if (detail.roomName.isNotEmpty) ...[ + const SizedBox(height: 10), + _kv('房间', detail.roomName), + ], + if (detail.remark.isNotEmpty) ...[ + const SizedBox(height: 6), + _kv('备注', detail.remark), + ], + if (detail.photoUrls.isNotEmpty) ...[ + const SizedBox(height: 12), + SizedBox( + height: 92, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: detail.photoUrls.length, + separatorBuilder: (_, _) => const SizedBox(width: 10), + itemBuilder: (context, i) => GestureDetector( + onTap: () => _previewPhoto(context, detail.photoUrls[i]), + child: ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.lg), + child: Image.network( + detail.photoUrls[i], + width: 92, + height: 92, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: 92, + height: 92, + color: AppColors.surfaceContainerHigh, + child: const XIcon( + 'camera', + size: 24, + color: AppColors.textMuted, + ), + ), + ), + ), + ), + ), + ), + ], + ], + ), + ); + } + + Widget _kv(String label, String value) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 48, + child: Text( + label, + style: AppTypography.labelCaps.copyWith(color: AppColors.textMuted), + ), + ), + Expanded( + child: Text( + value, + style: AppTypography.bodyMd.copyWith( + color: AppColors.onSurfaceVariant, + height: 1.5, + ), + ), + ), + ], + ); +} + +/// 文字操作(编辑 / 删除)。 +class _TextAction extends StatelessWidget { + const _TextAction({ + required this.label, + required this.color, + required this.onTap, + }); + + final String label; + final Color color; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2), + child: Text( + label, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: color), + ), + ), + ); + } +} + +/// 明细编辑弹层:建筑 / 楼层(必填)、房间(选填)、现场照片、备注。 +class _DetailEditorSheet extends ConsumerStatefulWidget { + const _DetailEditorSheet({required this.topology, this.initial}); + + final CustomerTopology topology; + final SurveyDetail? initial; + + @override + ConsumerState<_DetailEditorSheet> createState() => _DetailEditorSheetState(); +} + +class _DetailEditorSheetState extends ConsumerState<_DetailEditorSheet> { + late final TextEditingController _remark; + late List<({String key, String url})> _photos; + + int? _buildingId; + String _floorNo = ''; + int? _roomId; + String _roomName = ''; + bool _uploading = false; + + CustomerTopology get _topology => widget.topology; + + @override + void initState() { + super.initState(); + final initial = widget.initial; + _buildingId = (initial?.buildingId ?? 0) == 0 ? null : initial!.buildingId; + _floorNo = initial?.floorNo ?? ''; + _roomId = initial?.roomId; + _roomName = initial?.roomName ?? ''; + _remark = TextEditingController(text: initial?.remark ?? ''); + final keys = initial?.photos ?? const []; + final urls = initial?.photoUrls ?? const []; + _photos = [ + for (var i = 0; i < keys.length; i++) + (key: keys[i], url: i < urls.length ? urls[i] : ''), + ]; + } + + @override + void dispose() { + _remark.dispose(); + super.dispose(); + } + + TopologyBuilding? get _building => + _buildingId == null ? null : _topology.buildingOf(_buildingId!); + + Future _pickChoice( + List labels, + int current, + ValueChanged onSelected, + ) async { + if (labels.isEmpty) return; + final index = await showModalBottomSheet( + context: context, + backgroundColor: AppColors.surfaceContainerLowest, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)), + ), + builder: (ctx) => SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(ctx).size.height * 0.6, + ), + child: ListView( + shrinkWrap: true, + children: [ + for (var i = 0; i < labels.length; i++) + ListTile( + title: Text( + labels[i], + style: AppTypography.bodyMd.copyWith( + color: AppColors.onSurface, + ), + ), + trailing: i == current + ? const XIcon('check', size: 18, color: AppColors.primary) + : null, + onTap: () => Navigator.of(ctx).pop(i), + ), + ], + ), + ), + ), + ); + if (index != null) onSelected(index); + } + + void _pickBuilding() { + final buildings = _topology.buildings; + final current = buildings.indexWhere((b) => b.id == _buildingId); + _pickChoice(buildings.map((b) => b.buildingName).toList(), current, (i) { + final b = buildings[i]; + setState(() { + _buildingId = b.id; + // 建筑变更后楼层/房间重新选择 + _floorNo = ''; + _roomId = null; + _roomName = ''; + }); + }); + } + + void _pickFloor() { + final options = _building?.floorOptions ?? const []; + final current = options.indexWhere((o) => o.value == _floorNo); + _pickChoice(options.map((o) => o.label).toList(), current, (i) { + setState(() { + _floorNo = options[i].value; + _roomId = null; + _roomName = ''; + }); + }); + } + + void _pickRoom() { + final building = _building; + if (building == null || _floorNo.isEmpty) return; + final rooms = _topology.roomsOf(building.id, _floorNo); + if (rooms.isEmpty) return; + final labels = ['不选择房间', ...rooms.map((r) => r.roomName)]; + final current = rooms.indexWhere((r) => r.id == _roomId) + 1; + _pickChoice(labels, current, (i) { + setState(() { + if (i == 0) { + _roomId = null; + _roomName = ''; + } else { + _roomId = rooms[i - 1].id; + _roomName = rooms[i - 1].roomName; + } + }); + }); + } + + Future _addPhoto() async { + if (_uploading) return; + final source = await showModalBottomSheet( + context: context, + backgroundColor: AppColors.surfaceContainerLowest, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: Text( + '拍照', + style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface), + ), + onTap: () => Navigator.of(ctx).pop(ImageSource.camera), + ), + ListTile( + title: Text( + '从相册选择', + style: AppTypography.bodyMd.copyWith(color: AppColors.onSurface), + ), + onTap: () => Navigator.of(ctx).pop(ImageSource.gallery), + ), + ], + ), + ), + ); + if (source == null || !mounted) return; + + final file = await ImagePicker().pickImage(source: source, imageQuality: 80); + if (file == null || !mounted) return; + + setState(() => _uploading = true); + try { + final up = await ref.read(apiClientProvider).uploadImage(file.path); + if (!mounted) return; + setState(() => _photos = [..._photos, (key: up.key, url: up.url)]); + } catch (e) { + final message = e is ApiException && e.message.isNotEmpty + ? e.message + : '照片上传失败'; + if (mounted) showAppToast(message); + } finally { + if (mounted) setState(() => _uploading = false); + } + } + + void _confirm() { + final building = _building; + if (building == null) { + showAppToast('请选择建筑'); + return; + } + if (_floorNo.isEmpty) { + showAppToast('请选择楼层'); + return; + } + Navigator.of(context).pop( + SurveyDetail( + id: widget.initial?.id ?? 0, + buildingId: building.id, + buildingName: building.buildingName, + floorNo: _floorNo, + roomId: _roomId, + roomName: _roomName, + photos: _photos + .map((p) => p.key) + .where((k) => k.isNotEmpty) + .toList(), + photoUrls: _photos.map((p) => p.url).toList(), + remark: _remark.text.trim(), + ), + ); + } + + @override + Widget build(BuildContext context) { + final building = _building; + final floorOptions = building?.floorOptions ?? const []; + final floorLabel = floorOptions + .where((o) => o.value == _floorNo) + .map((o) => o.label) + .firstOrNull; + final rooms = building == null || _floorNo.isEmpty + ? const [] + : _topology.roomsOf(building.id, _floorNo); + + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: SafeArea( + top: false, + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.initial == null ? '新增勘察明细' : '编辑勘察明细', + style: AppTypography.headlineMd.copyWith( + color: AppColors.onSurface, + ), + ), + const SizedBox(height: 16), + _field( + label: '建筑', + required: true, + child: _pickerBox( + building?.buildingName ?? '', + '请选择建筑', + _pickBuilding, + ), + ), + const SizedBox(height: 12), + _field( + label: '楼层', + required: true, + child: _pickerBox( + floorLabel ?? '', + building == null + ? '请先选择建筑' + : (floorOptions.isEmpty ? '暂无可选楼层' : '请选择楼层'), + building == null || floorOptions.isEmpty ? null : _pickFloor, + ), + ), + const SizedBox(height: 12), + _field( + label: '房间', + child: _pickerBox( + _roomName, + _floorNo.isEmpty + ? '请先选择楼层' + : (rooms.isEmpty ? '暂无房间' : '选择房间(选填)'), + rooms.isEmpty ? null : _pickRoom, + ), + ), + const SizedBox(height: 12), + _field( + label: '现场照片', + child: _photoGrid(), + ), + const SizedBox(height: 12), + _field( + label: '备注', + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: _fieldDecoration, + child: TextField( + controller: _remark, + maxLines: 4, + minLines: 3, + style: AppTypography.bodyMd.copyWith( + color: AppColors.onSurface, + ), + cursorColor: AppColors.primary, + decoration: InputDecoration( + isDense: true, + border: InputBorder.none, + hintText: '补充说明(选填)', + hintStyle: const TextStyle( + fontSize: 15, + color: AppColors.textPlaceholder, + ), + ), + ), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: XPrimaryButton( + label: '取消', + onPressed: () => Navigator.of(context).pop(), + background: AppColors.surface, + foreground: AppColors.primary, + height: 48, + fontSize: 15, + ), + ), + const SizedBox(width: 12), + Expanded( + child: XPrimaryButton( + label: '确定', + onPressed: _uploading ? null : _confirm, + height: 48, + fontSize: 15, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _field({ + required String label, + required Widget child, + bool required = false, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Text( + label, + style: AppTypography.bodySm.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.onSurfaceVariant, + ), + ), + if (required) + Text( + ' *', + style: AppTypography.bodySm.copyWith( + fontWeight: FontWeight.w600, + color: AppColors.statusError, + ), + ), + ], + ), + ), + child, + ], + ); + } + + Widget _photoGrid() { + return SizedBox( + height: 84, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + for (var i = 0; i < _photos.length; i++) ...[ + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(AppRadius.lg), + child: _photos[i].url.isNotEmpty + ? Image.network( + _photos[i].url, + width: 84, + height: 84, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => Container( + width: 84, + height: 84, + color: AppColors.surfaceContainerHigh, + child: const XIcon( + 'camera', + size: 22, + color: AppColors.textMuted, + ), + ), + ) + : Container( + width: 84, + height: 84, + color: AppColors.surfaceContainerHigh, + child: const XIcon( + 'camera', + size: 22, + color: AppColors.textMuted, + ), + ), + ), + Positioned( + top: 0, + right: 0, + child: GestureDetector( + onTap: () => setState(() { + _photos = [..._photos]..removeAt(i); + }), + child: Container( + padding: const EdgeInsets.all(2), + decoration: const BoxDecoration( + color: Color(0x8C0D1C2C), + shape: BoxShape.circle, + ), + child: const XIcon( + 'close', + size: 12, + color: Colors.white, + ), + ), + ), + ), + ], + ), + const SizedBox(width: 10), + ], + GestureDetector( + onTap: _uploading ? null : _addPhoto, + behavior: HitTestBehavior.opaque, + child: Container( + width: 84, + height: 84, + decoration: BoxDecoration( + color: AppColors.surfaceBright, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: AppColors.borderRule), + ), + child: _uploading + ? const Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + ), + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + XIcon('camera', size: 24, color: AppColors.primary), + SizedBox(height: 4), + Text( + '添加', + style: TextStyle( + fontSize: 12, + color: AppColors.primary, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _pickerBox(String value, String placeholder, VoidCallback? onTap) { + final empty = value.isEmpty; + return GestureDetector( + onTap: onTap, + child: Container( + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: _fieldDecoration, + child: Row( + children: [ + Expanded( + child: Text( + empty ? placeholder : value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTypography.bodyMd.copyWith( + color: empty ? AppColors.textPlaceholder : AppColors.onSurface, + ), + ), + ), + if (onTap != null) + const XIcon('forward', size: 16, color: AppColors.outline), + ], + ), + ), + ); + } + + BoxDecoration get _fieldDecoration => BoxDecoration( + color: AppColors.surfaceBright, + border: Border.all(color: AppColors.borderRule), + borderRadius: BorderRadius.circular(AppRadius.regular), + ); +} diff --git a/lib/features/profile/presentation/profile_tab.dart b/lib/features/profile/presentation/profile_tab.dart index 905a39a..651b7ac 100644 --- a/lib/features/profile/presentation/profile_tab.dart +++ b/lib/features/profile/presentation/profile_tab.dart @@ -68,7 +68,7 @@ class ProfileTab extends ConsumerWidget { ), ), _profileCard(worker, profile?.primaryCert), - _menuSection(), + _menuSection(context), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: _logoutButton(ref), @@ -323,25 +323,29 @@ class ProfileTab extends ConsumerWidget { ], ); - /// 两组 `ios-card` 菜单,行点击仅提示(同源实现)。 - Widget _menuSection() { + /// 两组 `ios-card` 菜单,除「修改密码」跳转外其余行点击仅提示(同源实现)。 + Widget _menuSection(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), child: Column( children: [ - _menuGroup(const [ - ('order', '我的订单'), - ('earnings', '收益记录'), - ('notify', '账户设置'), + _menuGroup(context, const [ + ('order', '我的订单', null), + ('earnings', '收益记录', null), + ('settings', '修改密码', '/change-password'), + ('notify', '账户设置', null), ]), const SizedBox(height: 12), - _menuGroup(const [('star', '帮助中心'), ('shandian', '关于我们')]), + _menuGroup(context, const [ + ('star', '帮助中心', null), + ('shandian', '关于我们', null), + ]), ], ), ); } - Widget _menuGroup(List<(String, String)> items) { + Widget _menuGroup(BuildContext context, List<(String, String, String?)> items) { return XCard( radius: AppRadius.lg, padding: const EdgeInsets.symmetric(vertical: 4), @@ -354,16 +358,16 @@ class ProfileTab extends ConsumerWidget { margin: const EdgeInsets.symmetric(horizontal: 16), color: AppColors.borderSoft, ), - _menuRow(items[i].$1, items[i].$2), + _menuRow(context, items[i].$1, items[i].$2, items[i].$3), ], ], ), ); } - Widget _menuRow(String icon, String label) { + Widget _menuRow(BuildContext context, String icon, String label, String? route) { return GestureDetector( - onTap: () => showAppToast(label), + onTap: () => route == null ? showAppToast(label) : context.push(route), behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.all(16), diff --git a/pubspec.lock b/pubspec.lock index 7e743df..fa9f166 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -89,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: f141ea4f277af142a0356955707f6556f37b03947d39d55585981a06ca437bd6 + url: "https://pub.dev" + source: hosted + version: "0.3.5+5" crypto: dependency: transitive description: @@ -145,6 +153,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: da76400e7872ce7637ffdce12749ec24169c25f6195c28372208e65a24bcd2ab + url: "https://pub.dev" + source: hosted + version: "0.9.4+1" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: d57c62362766b5e7ae739448650b66c6aab7a68ba7ecc65e04018652645ae0f4 + url: "https://pub.dev" + source: hosted + version: "0.9.5+1" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: fbefc5fb92c6d3cbe8d284a2cd971b593bb07d2cd6da8557b81a862250b4acec + url: "https://pub.dev" + source: hosted + version: "0.9.3+6" fixnum: dependency: transitive description: @@ -171,6 +211,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.dev" + source: hosted + version: "2.0.35" flutter_riverpod: dependency: "direct main" description: @@ -245,6 +293,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: e71b1bd9e40124041128d60fc3034c7b1598e1bba4e570bba0c70301c817013a + url: "https://pub.dev" + source: hosted + version: "1.2.4" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f + url: "https://pub.dev" + source: hosted + version: "0.8.13+17" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: "4fb88d4350ffc208c2a09fc321db1f80282688716b8cfe10c8c80acc5ae8934a" + url: "https://pub.dev" + source: hosted + version: "0.8.13+10" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "2dee4b4e3be429db71472c9c559c981498070a61af1ba82e0e4986fc5dadc042" + url: "https://pub.dev" + source: hosted + version: "2.11.2" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: transitive description: @@ -740,4 +852,4 @@ packages: version: "3.1.4" sdks: dart: ">=3.11.5 <4.0.0" - flutter: ">=3.38.0" + flutter: ">=3.41.0" diff --git a/pubspec.yaml b/pubspec.yaml index 44a9358..f3f7a01 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: dio: ^5.11.1 shared_preferences: ^2.5.5 flutter_svg: ^2.3.0 + image_picker: ^1.2.3 dev_dependencies: flutter_test: