diff --git a/lib/pages/feedback_page.dart b/lib/pages/feedback_page.dart new file mode 100644 index 00000000..ea4888e6 --- /dev/null +++ b/lib/pages/feedback_page.dart @@ -0,0 +1,282 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../services/feedback_service.dart'; +import '../utils/platform.dart'; +import '../widgets/adaptive_button.dart'; +import '../widgets/adaptive_page_navigation.dart'; +import '../widgets/app_shell/app_shell_metrics.dart'; +import '../widgets/blurred_app_bar.dart'; +import '../widgets/ios/ios_native_navigation_bar.dart'; + +class FeedbackPage extends StatefulWidget { + const FeedbackPage({super.key, required this.service}); + final FeedbackService service; + + @override + State createState() => _FeedbackPageState(); +} + +class _FeedbackPageState extends State { + final _message = TextEditingController(); + final _contact = TextEditingController(); + final _images = []; + bool _picking = false; + bool _includeUsername = false; + bool _includeEmail = false; + bool _busy = false; + bool _sent = false; + String? _error; + + @override + void dispose() { + _message.dispose(); + _contact.dispose(); + super.dispose(); + } + + Future _submit() async { + if (_busy || _picking || _sent) return; + FocusScope.of(context).unfocus(); + setState(() { + _busy = true; + _error = null; + }); + try { + await widget.service.submit( + _message.text, + includeUsername: _includeUsername, + includeEmail: _includeEmail, + contact: _contact.text, + images: _images, + onImageChanged: _refreshImages, + ); + if (mounted) setState(() => _sent = true); + } catch (error) { + if (mounted) { + setState(() { + _error = error is TimeoutException + ? '未能确认送达,内容已保留,请稍后再试' + : error is Exception && error.toString().startsWith('Exception: ') + ? error.toString().substring(11) + : '反馈未发送成功,内容已保留'; + }); + } + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _pickImages() async { + if (_picking || _busy || _images.length >= 9) return; + setState(() { + _picking = true; + _error = null; + }); + try { + final files = await ImagePicker().pickMultiImage( + maxWidth: 1920, + maxHeight: 1920, + imageQuality: 85, + requestFullMetadata: false, + ); + if (files.length > 9 - _images.length) throw Exception('最多添加 9 张图片'); + final selected = []; + for (final file in files) { + if (await file.length() > FeedbackService.imageLimit) throw Exception('每张图片请勿超过 4 MB'); + final bytes = await file.readAsBytes(); + try { + final decoded = await decodeImageFromList(bytes); + decoded.dispose(); + } catch (_) { + throw Exception('图片无法读取,请换一张图片'); + } + selected.add(FeedbackImage(bytes)); + } + if (mounted) setState(() => _images.addAll(selected)); + } catch (error) { + if (mounted) { + setState( + () => _error = error is Exception && error.toString().startsWith('Exception: ') + ? error.toString().substring(11) + : '未能添加图片,请重试', + ); + } + } finally { + if (mounted) setState(() => _picking = false); + } + } + + void _refreshImages() { + if (mounted) setState(() {}); + } + + Future _retryImage(FeedbackImage image) async { + if (_busy || _picking) return; + setState(() { + _busy = true; + _error = null; + }); + try { + await widget.service.uploadImage(image, onChanged: _refreshImages); + } catch (_) { + if (mounted) setState(() => _error = '图片上传失败,可重试或移除'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final useIosChrome = isIos(); + final topInset = useIosChrome || usesLegacyIosChrome() + ? 0.0 + : adaptiveTopBarHeight() + MediaQuery.viewPaddingOf(context).top; + return Scaffold( + extendBodyBehindAppBar: !useIosChrome && !usesLegacyIosChrome(), + appBar: useIosChrome + ? IosNativeNavigationBar( + title: '问题反馈 / 功能建议', + leadingItems: const [ + IosNativeNavigationBarItem( + id: 'back', + title: 'Settings', + sfSymbol: 'chevron.left', + accessibilityLabel: '返回 Settings', + placementGroup: 'leading-main', + ), + ], + onItemPressed: (_) => unawaited(maybePopAdaptivePage(context)), + ) + : const BlurredAppBar(title: Text('问题反馈 / 功能建议')), + body: ListView( + padding: EdgeInsets.fromLTRB( + 20, + topInset + 20, + 20, + AppShellMetrics.bottomContentPaddingOf(context) + 20, + ), + children: _sent + ? const [ + SizedBox(height: 40), + Icon(Icons.check_circle_outline, size: 64), + SizedBox(height: 20), + Text('反馈已发送', textAlign: TextAlign.center), + ] + : [ + TextField( + controller: _message, + enabled: !_busy, + minLines: 6, + maxLines: 12, + maxLength: 2000, + decoration: const InputDecoration( + hintText: '描述问题或建议', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + if (_images.isNotEmpty) + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (var i = 0; i < _images.length; i++) + Stack( + children: [ + Image.memory( + _images[i].bytes, + width: 88, + height: 88, + fit: BoxFit.cover, + ), + if (_images[i].uploading) + const Positioned.fill( + child: Center(child: CircularProgressIndicator()), + ), + if (_images[i].failed) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: FilledButton( + onPressed: + _busy ? null : () => unawaited(_retryImage(_images[i])), + child: const Text('重试'), + ), + ) + else if (_images[i].uploaded) + const Positioned( + bottom: 0, + left: 0, + child: Icon(Icons.check_circle, color: Colors.green), + ), + Positioned( + top: 0, + right: 0, + child: IconButton.filledTonal( + tooltip: '移除第 ${i + 1} 张图片', + icon: const Icon(Icons.close, size: 18), + onPressed: _busy || _picking + ? null + : () => setState(() => _images.removeAt(i)), + ), + ), + ], + ), + ], + ), + AdaptiveButton( + label: '添加图片(${_images.length}/9)', + icon: Icons.add_photo_alternate_outlined, + sfSymbol: 'photo.badge.plus', + loading: _picking, + onPressed: _busy || _picking || _images.length >= 9 + ? null + : () => unawaited(_pickImages()), + ), + CheckboxListTile( + title: const Text('发送用户名'), + value: _includeUsername, + onChanged: _busy ? null : (value) => setState(() => _includeUsername = value!), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + CheckboxListTile( + title: const Text('发送邮箱'), + value: _includeEmail, + onChanged: _busy ? null : (value) => setState(() => _includeEmail = value!), + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + ), + TextField( + controller: _contact, + enabled: !_busy, + maxLength: 200, + decoration: const InputDecoration( + labelText: '补充联系方式(选填)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + const Text('自动附带 App 版本和平台。'), + if (_error != null) ...[ + const SizedBox(height: 16), + Text(_error!, style: TextStyle(color: Theme.of(context).colorScheme.error)), + ], + const SizedBox(height: 20), + AdaptiveButton( + label: '提交反馈', + icon: Icons.send_outlined, + sfSymbol: 'paperplane', + role: AdaptiveButtonRole.prominent, + loading: _busy, + onPressed: _busy || _picking ? null : () => unawaited(_submit()), + ), + ], + ), + ); + } +} diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 599e8cd4..18a42df3 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -8,6 +8,8 @@ import 'package:package_info_plus/package_info_plus.dart'; import 'package:techpie/services/auth_service.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../services/api_base_url.dart'; +import '../services/feedback_service.dart'; import '../services/service_provider.dart'; import '../services/sync_service.dart'; import '../services/theme_service.dart'; @@ -32,6 +34,7 @@ import 'account_deletion_page.dart'; import 'debug_log_page.dart'; import 'debug_webview_page.dart'; import 'developer_lab_page.dart'; +import 'feedback_page.dart'; import 'sync_settings_page.dart'; import 'third_party_accounts_page.dart'; import 'watch_settings_page.dart'; @@ -331,6 +334,21 @@ class _SettingsPageState extends State { ), const Divider(), + ListTile( + leading: const Icon(Icons.feedback_outlined), + title: const Text('问题反馈 / 功能建议'), + trailing: const Icon(Icons.chevron_right), + onTap: () async { + if (!auth.isLoggedIn) await startUniAuthLogin(context); + if (!context.mounted || !auth.isLoggedIn) return; + await pushAdaptivePage( + context, + builder: (_) => FeedbackPage( + service: FeedbackService(auth, apiBaseUrl(sp.storageService)), + ), + ); + }, + ), ListTile( leading: const Icon(Icons.privacy_tip_outlined), title: const Text('隐私政策与使用支持'), diff --git a/lib/services/feedback_service.dart b/lib/services/feedback_service.dart new file mode 100644 index 00000000..0ddd4963 --- /dev/null +++ b/lib/services/feedback_service.dart @@ -0,0 +1,126 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'auth_service.dart'; + +class FeedbackImage { + FeedbackImage(this.bytes); + final Uint8List bytes; + bool uploading = false; + bool failed = false; + String? _userId; + Map? _receipt; + bool get uploaded => _receipt != null; +} + +class FeedbackService { + static const imageLimit = 4000000; + FeedbackService(this._auth, this._baseUrl); + + final AuthService _auth; + final String _baseUrl; + + Future submit( + String message, { + bool includeUsername = false, + bool includeEmail = false, + String contact = '', + List images = const [], + VoidCallback? onImageChanged, + }) async { + final text = message.trim(); + if (text.isEmpty || text.length > 2000) { + throw Exception('请填写 1–2000 字的反馈内容'); + } + if (contact.trim().length > 200) throw Exception('联系方式请勿超过 200 字'); + if (images.length > 9) throw Exception('最多添加 9 张图片'); + if (images.any((image) => image.bytes.isEmpty || image.bytes.length > imageLimit)) { + throw Exception('每张图片需在 4 MB 以内且不能为空'); + } + final userId = _userId(); + final info = await PackageInfo.fromPlatform(); + for (final image in images) { + if (_auth.session?.userId != userId) throw Exception('登录已变化,请重新提交'); + await uploadImage(image, onChanged: onImageChanged); + } + final token = await _token(userId); + // Feedback is user-authored private text; keep it out of diagnostic logs. + final response = await http + .post( + Uri.parse('$_baseUrl/feedback'), + headers: { + 'Authorization': 'Bearer $token', + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'message': text, + 'version': '${info.version}+${info.buildNumber}', + 'platform': kIsWeb ? 'web' : defaultTargetPlatform.name, + 'includeUsername': includeUsername, + 'includeEmail': includeEmail, + 'contact': contact.trim(), + 'images': images.map((image) => image._receipt).toList(), + }), + ) + .timeout(const Duration(seconds: 25)); + _result(response); + } + + Future uploadImage(FeedbackImage image, {VoidCallback? onChanged}) async { + final userId = _userId(); + if (image._userId == userId && image.uploaded) return; + if (image.bytes.isEmpty || image.bytes.length > imageLimit) { + throw Exception('每张图片需在 4 MB 以内且不能为空'); + } + image._receipt = null; + image.failed = false; + image.uploading = true; + onChanged?.call(); + try { + final token = await _token(userId); + final request = http.MultipartRequest('POST', Uri.parse('$_baseUrl/feedback/images')) + ..headers['Authorization'] = 'Bearer $token' + ..files.add(http.MultipartFile.fromBytes('image', image.bytes, filename: 'feedback-image')); + final response = + await request.send().then(http.Response.fromStream).timeout(const Duration(seconds: 35)); + final receipt = _result(response)['data'] as Map; + if (_auth.session?.userId != userId) throw Exception('登录已变化,请重新提交'); + image._userId = userId; + image._receipt = receipt; + } catch (_) { + image.failed = true; + rethrow; + } finally { + image.uploading = false; + onChanged?.call(); + } + } + + String _userId() { + final userId = _auth.session?.userId; + if (userId == null || userId.isEmpty) throw Exception('请先登录 TechPie'); + return userId; + } + + Future _token(String userId) async { + await _auth.tryRenewSession(); + final session = _auth.session; + final token = session?.geekpieToken; + if (session?.userId != userId || token == null || token.isEmpty) { + throw Exception('登录已变化,请重新登录后提交'); + } + return token; + } + + Map _result(http.Response response) { + if (response.statusCode == 413) throw Exception('每张图片请勿超过 4 MB'); + final body = jsonDecode(response.body) as Map; + if (response.statusCode != 200 || body['success'] != true) { + throw Exception(body['error'] as String? ?? '反馈未发送成功,请稍后再试'); + } + return body; + } +} diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements index 8f2eb20e..17a3b37e 100644 --- a/macos/Runner/DebugProfile.entitlements +++ b/macos/Runner/DebugProfile.entitlements @@ -12,6 +12,8 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-only + keychain-access-groups $(AppIdentifierPrefix)$(CFBundleIdentifier) diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements index a042f0a3..3ecf203f 100644 --- a/macos/Runner/Release.entitlements +++ b/macos/Runner/Release.entitlements @@ -8,6 +8,8 @@ com.apple.security.network.client + com.apple.security.files.user-selected.read-only + keychain-access-groups $(AppIdentifierPrefix)$(CFBundleIdentifier)