From ad48ce97199273951eec3b00d8239304c88cf6ef Mon Sep 17 00:00:00 2001 From: AvianJay Date: Mon, 9 Nov 2026 22:58:55 +0800 Subject: [PATCH] feat(feedback): add optional context and opt-in diagnostics --- docs/feedback.md | 21 +++ lib/core/feedback_models.dart | 38 ++++ lib/core/feedback_service.dart | 29 ++- lib/l10n/app_en.arb | 23 +++ lib/l10n/app_localizations.dart | 132 ++++++++++++++ lib/l10n/app_localizations_en.dart | 73 ++++++++ lib/l10n/app_localizations_zh.dart | 136 ++++++++++++++ lib/l10n/app_zh.arb | 23 +++ lib/l10n/app_zh_TW.arb | 23 +++ lib/screens/feedback_screen.dart | 165 ++++++++++++++--- test/feedback_screen_test.dart | 273 +++++++++++++++++++++++++++++ test/feedback_service_test.dart | 101 +++++++++++ 12 files changed, 1011 insertions(+), 26 deletions(-) create mode 100644 docs/feedback.md create mode 100644 lib/core/feedback_models.dart create mode 100644 test/feedback_screen_test.dart diff --git a/docs/feedback.md b/docs/feedback.md new file mode 100644 index 00000000..d8398a19 --- /dev/null +++ b/docs/feedback.md @@ -0,0 +1,21 @@ +# 意見回饋補充資訊 + +登入後仍只需填寫標題與內容。展開「更多資訊(選填)」可提供回饋類型、相關路線/站牌及聯絡方式,後兩項各最多 200 字。未填的選填欄位不會送出。 + +「附上 App 與顯示資訊」每次進入頁面預設關閉。開啟後會預覽將附上的 App 版本、建置編號、程式版本、平台、語言、畫面尺寸、文字縮放與顯示主題。送出時只有開啟此選項才會加入 `diagnostics`;取消後不附上。收合選填區塊保留已填內容和開關選擇,送出失敗也保留內容供重試。 + +API 為 `POST /api/v1/feedback`,新增欄位是 `category`、`route_context`、`contact`、`diagnostics`。回饋類型使用 `bug`、`data`、`feature`、`other`。既有的驗證身分與 User-Agent 請求標頭維持原行為。 + +需要搭配 `busapiserver` 的 API、資料庫升級與管理後台更新;先部署後端再發布 App。後端啟動時自動補上選填欄位,舊版 App 與舊回饋資料繼續相容;舊後端不接受新欄位。 + +驗證: + +```sh +flutter test test/feedback_service_test.dart test/feedback_screen_test.dart +``` + +後端專案內執行: + +```sh +python -m unittest test_feedback_api test_rate_limit +``` diff --git a/lib/core/feedback_models.dart b/lib/core/feedback_models.dart new file mode 100644 index 00000000..54c0d9a0 --- /dev/null +++ b/lib/core/feedback_models.dart @@ -0,0 +1,38 @@ +enum FeedbackCategory { bug, data, feature, other } + +/// Only the app/display fields shown in the feedback consent preview. +class FeedbackDiagnostics { + const FeedbackDiagnostics({ + required this.appVersion, + required this.buildNumber, + required this.commitHash, + required this.platform, + required this.locale, + required this.screenWidth, + required this.screenHeight, + required this.textScale, + required this.brightness, + }); + + final String appVersion; + final String buildNumber; + final String commitHash; + final String platform; + final String locale; + final int screenWidth; + final int screenHeight; + final double textScale; + final String brightness; + + Map toJson() => { + 'app_version': appVersion, + 'build_number': buildNumber, + 'commit_hash': commitHash, + 'platform': platform, + 'locale': locale, + 'screen_width': screenWidth, + 'screen_height': screenHeight, + 'text_scale': textScale, + 'brightness': brightness, + }; +} diff --git a/lib/core/feedback_service.dart b/lib/core/feedback_service.dart index c730623e..d12ff5f6 100644 --- a/lib/core/feedback_service.dart +++ b/lib/core/feedback_service.dart @@ -6,6 +6,7 @@ import 'api_http.dart'; import 'api_config.dart'; import 'api_user_agent.dart'; import 'auth_token_store.dart'; +import 'feedback_models.dart'; import 'http_error_utils.dart'; class FeedbackRateLimitException implements Exception { @@ -46,6 +47,10 @@ class FeedbackService { Future submitFeedback({ required String title, required String content, + FeedbackCategory? category, + String? routeContext, + String? contact, + FeedbackDiagnostics? diagnostics, }) async { final cleanedTitle = title.trim(); final cleanedContent = content.trim(); @@ -62,13 +67,24 @@ class FeedbackService { throw ArgumentError('內容不能超過 4000 字。'); } + final cleanedRouteContext = _optionalText(routeContext, 'route_context'); + final cleanedContact = _optionalText(contact, 'contact'); + final payload = { + 'title': cleanedTitle, + 'content': cleanedContent, + if (category != null) 'category': category.name, + 'route_context': ?cleanedRouteContext, + 'contact': ?cleanedContact, + if (diagnostics != null) 'diagnostics': diagnostics.toJson(), + }; + final http.Response response; try { response = await apiPost( _client, Uri.parse('${ApiConfig.baseUrl}/api/v1/feedback'), headers: ApiUserAgent.applyTo(apiJsonContentHeaders), - body: jsonEncode({'title': cleanedTitle, 'content': cleanedContent}), + body: jsonEncode(payload), ); } on ApiRequestException catch (error) { if (error.statusCode != 429) rethrow; @@ -99,6 +115,17 @@ class FeedbackService { decoded.map((key, value) => MapEntry(key.toString(), value)), ); } + + void dispose() => _client.close(); +} + +String? _optionalText(String? value, String field) { + final cleaned = value?.trim(); + if (cleaned == null || cleaned.isEmpty) return null; + if (cleaned.length > 200) { + throw ArgumentError.value(null, field, 'Must be at most 200 characters.'); + } + return cleaned; } int _jsonInt(Object? value) { diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index da8fa294..0f739689 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -848,6 +848,29 @@ "feedbackContentRequired": "Enter some details", "feedbackContentTooLong": "The details can be at most {max} characters.", "@feedbackContentTooLong": {"placeholders": {"max": {"type": "int"}}}, + "feedbackMoreInfo": "More information (optional)", + "feedbackMoreInfoHint": "Additional context can help us understand the issue. You can also submit without it.", + "feedbackCategoryLabel": "Category (optional)", + "feedbackCategoryNone": "Not specified", + "feedbackCategoryBug": "Bug report", + "feedbackCategoryData": "Transit data issue", + "feedbackCategoryFeature": "Feature suggestion", + "feedbackCategoryOther": "Other", + "feedbackRouteContextLabel": "Related route / stop (optional)", + "feedbackRouteContextHint": "For example: Taipei route 307, toward Banqiao, Taipei Main Station", + "feedbackContactLabel": "Contact details (optional)", + "feedbackContactHint": "For example: email or Discord username for follow-up questions", + "feedbackOptionalTooLong": "Use no more than {max} characters", + "feedbackIncludeDiagnostics": "Include app and display information", + "feedbackDiagnosticsHint": "When enabled, the version, platform, language and display settings shown below will be included to help troubleshoot the issue.", + "feedbackDiagnosticVersion": "App version", + "feedbackDiagnosticCommit": "Code revision", + "feedbackDiagnosticPlatform": "Platform", + "feedbackDiagnosticLocale": "Language", + "feedbackDiagnosticScreen": "Screen size (logical pixels)", + "feedbackDiagnosticTextScale": "Text scale (at 14pt)", + "feedbackDiagnosticTheme": "Theme", + "@feedbackOptionalTooLong": {"placeholders": {"max": {"type": "int"}}}, "feedbackSubmitting": "Sending...", "feedbackSubmit": "Send feedback", "feedbackInvalidFormat": "The feedback format is invalid.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4d0ec0a8..6ba3d245 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -4342,6 +4342,138 @@ abstract class AppLocalizations { /// **'The details can be at most {max} characters.'** String feedbackContentTooLong(int max); + /// No description provided for @feedbackMoreInfo. + /// + /// In en, this message translates to: + /// **'More information (optional)'** + String get feedbackMoreInfo; + + /// No description provided for @feedbackMoreInfoHint. + /// + /// In en, this message translates to: + /// **'Additional context can help us understand the issue. You can also submit without it.'** + String get feedbackMoreInfoHint; + + /// No description provided for @feedbackCategoryLabel. + /// + /// In en, this message translates to: + /// **'Category (optional)'** + String get feedbackCategoryLabel; + + /// No description provided for @feedbackCategoryNone. + /// + /// In en, this message translates to: + /// **'Not specified'** + String get feedbackCategoryNone; + + /// No description provided for @feedbackCategoryBug. + /// + /// In en, this message translates to: + /// **'Bug report'** + String get feedbackCategoryBug; + + /// No description provided for @feedbackCategoryData. + /// + /// In en, this message translates to: + /// **'Transit data issue'** + String get feedbackCategoryData; + + /// No description provided for @feedbackCategoryFeature. + /// + /// In en, this message translates to: + /// **'Feature suggestion'** + String get feedbackCategoryFeature; + + /// No description provided for @feedbackCategoryOther. + /// + /// In en, this message translates to: + /// **'Other'** + String get feedbackCategoryOther; + + /// No description provided for @feedbackRouteContextLabel. + /// + /// In en, this message translates to: + /// **'Related route / stop (optional)'** + String get feedbackRouteContextLabel; + + /// No description provided for @feedbackRouteContextHint. + /// + /// In en, this message translates to: + /// **'For example: Taipei route 307, toward Banqiao, Taipei Main Station'** + String get feedbackRouteContextHint; + + /// No description provided for @feedbackContactLabel. + /// + /// In en, this message translates to: + /// **'Contact details (optional)'** + String get feedbackContactLabel; + + /// No description provided for @feedbackContactHint. + /// + /// In en, this message translates to: + /// **'For example: email or Discord username for follow-up questions'** + String get feedbackContactHint; + + /// No description provided for @feedbackOptionalTooLong. + /// + /// In en, this message translates to: + /// **'Use no more than {max} characters'** + String feedbackOptionalTooLong(int max); + + /// No description provided for @feedbackIncludeDiagnostics. + /// + /// In en, this message translates to: + /// **'Include app and display information'** + String get feedbackIncludeDiagnostics; + + /// No description provided for @feedbackDiagnosticsHint. + /// + /// In en, this message translates to: + /// **'When enabled, the version, platform, language and display settings shown below will be included to help troubleshoot the issue.'** + String get feedbackDiagnosticsHint; + + /// No description provided for @feedbackDiagnosticVersion. + /// + /// In en, this message translates to: + /// **'App version'** + String get feedbackDiagnosticVersion; + + /// No description provided for @feedbackDiagnosticCommit. + /// + /// In en, this message translates to: + /// **'Code revision'** + String get feedbackDiagnosticCommit; + + /// No description provided for @feedbackDiagnosticPlatform. + /// + /// In en, this message translates to: + /// **'Platform'** + String get feedbackDiagnosticPlatform; + + /// No description provided for @feedbackDiagnosticLocale. + /// + /// In en, this message translates to: + /// **'Language'** + String get feedbackDiagnosticLocale; + + /// No description provided for @feedbackDiagnosticScreen. + /// + /// In en, this message translates to: + /// **'Screen size (logical pixels)'** + String get feedbackDiagnosticScreen; + + /// No description provided for @feedbackDiagnosticTextScale. + /// + /// In en, this message translates to: + /// **'Text scale (at 14pt)'** + String get feedbackDiagnosticTextScale; + + /// No description provided for @feedbackDiagnosticTheme. + /// + /// In en, this message translates to: + /// **'Theme'** + String get feedbackDiagnosticTheme; + /// No description provided for @feedbackSubmitting. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 75ac3856..3818a6fd 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2701,6 +2701,79 @@ class AppLocalizationsEn extends AppLocalizations { return 'The details can be at most $max characters.'; } + @override + String get feedbackMoreInfo => 'More information (optional)'; + + @override + String get feedbackMoreInfoHint => + 'Additional context can help us understand the issue. You can also submit without it.'; + + @override + String get feedbackCategoryLabel => 'Category (optional)'; + + @override + String get feedbackCategoryNone => 'Not specified'; + + @override + String get feedbackCategoryBug => 'Bug report'; + + @override + String get feedbackCategoryData => 'Transit data issue'; + + @override + String get feedbackCategoryFeature => 'Feature suggestion'; + + @override + String get feedbackCategoryOther => 'Other'; + + @override + String get feedbackRouteContextLabel => 'Related route / stop (optional)'; + + @override + String get feedbackRouteContextHint => + 'For example: Taipei route 307, toward Banqiao, Taipei Main Station'; + + @override + String get feedbackContactLabel => 'Contact details (optional)'; + + @override + String get feedbackContactHint => + 'For example: email or Discord username for follow-up questions'; + + @override + String feedbackOptionalTooLong(int max) { + return 'Use no more than $max characters'; + } + + @override + String get feedbackIncludeDiagnostics => + 'Include app and display information'; + + @override + String get feedbackDiagnosticsHint => + 'When enabled, the version, platform, language and display settings shown below will be included to help troubleshoot the issue.'; + + @override + String get feedbackDiagnosticVersion => 'App version'; + + @override + String get feedbackDiagnosticCommit => 'Code revision'; + + @override + String get feedbackDiagnosticPlatform => 'Platform'; + + @override + String get feedbackDiagnosticLocale => 'Language'; + + @override + String get feedbackDiagnosticScreen => 'Screen size (logical pixels)'; + + @override + String get feedbackDiagnosticTextScale => 'Text scale (at 14pt)'; + + @override + String get feedbackDiagnosticTheme => 'Theme'; + @override String get feedbackSubmitting => 'Sending...'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 0e7a7a1d..67f35211 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2457,6 +2457,74 @@ class AppLocalizationsZh extends AppLocalizations { return '內文最多 $max 個字'; } + @override + String get feedbackMoreInfo => '更多資訊(選填)'; + + @override + String get feedbackMoreInfoHint => '補充資訊可協助我們了解問題,也可以直接送出。'; + + @override + String get feedbackCategoryLabel => '回饋類型(選填)'; + + @override + String get feedbackCategoryNone => '不指定'; + + @override + String get feedbackCategoryBug => '問題回報'; + + @override + String get feedbackCategoryData => '交通資料錯誤'; + + @override + String get feedbackCategoryFeature => '功能建議'; + + @override + String get feedbackCategoryOther => '其他'; + + @override + String get feedbackRouteContextLabel => '相關路線/站牌(選填)'; + + @override + String get feedbackRouteContextHint => '例如:臺北市 307,往板橋,臺北車站'; + + @override + String get feedbackContactLabel => '聯絡方式(選填)'; + + @override + String get feedbackContactHint => '例如:Email 或 Discord 帳號,方便我們追問細節'; + + @override + String feedbackOptionalTooLong(int max) { + return '此欄位最多 $max 個字'; + } + + @override + String get feedbackIncludeDiagnostics => '附上 App 與顯示資訊'; + + @override + String get feedbackDiagnosticsHint => '選擇後會附上以下版本、平台、語言與顯示設定,協助排查問題。'; + + @override + String get feedbackDiagnosticVersion => 'App 版本'; + + @override + String get feedbackDiagnosticCommit => '程式版本'; + + @override + String get feedbackDiagnosticPlatform => '平台'; + + @override + String get feedbackDiagnosticLocale => '語言'; + + @override + String get feedbackDiagnosticScreen => '畫面尺寸(邏輯像素)'; + + @override + String get feedbackDiagnosticTextScale => '文字縮放(以 14pt 為基準)'; + + @override + String get feedbackDiagnosticTheme => '顯示主題'; + @override String get feedbackSubmitting => '送出中…'; @@ -5789,6 +5857,74 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { return '內文最多 $max 個字'; } + @override + String get feedbackMoreInfo => '更多資訊(選填)'; + + @override + String get feedbackMoreInfoHint => '補充資訊可協助我們了解問題,也可以直接送出。'; + + @override + String get feedbackCategoryLabel => '回饋類型(選填)'; + + @override + String get feedbackCategoryNone => '不指定'; + + @override + String get feedbackCategoryBug => '問題回報'; + + @override + String get feedbackCategoryData => '交通資料錯誤'; + + @override + String get feedbackCategoryFeature => '功能建議'; + + @override + String get feedbackCategoryOther => '其他'; + + @override + String get feedbackRouteContextLabel => '相關路線/站牌(選填)'; + + @override + String get feedbackRouteContextHint => '例如:臺北市 307,往板橋,臺北車站'; + + @override + String get feedbackContactLabel => '聯絡方式(選填)'; + + @override + String get feedbackContactHint => '例如:Email 或 Discord 帳號,方便我們追問細節'; + + @override + String feedbackOptionalTooLong(int max) { + return '此欄位最多 $max 個字'; + } + + @override + String get feedbackIncludeDiagnostics => '附上 App 與顯示資訊'; + + @override + String get feedbackDiagnosticsHint => '選擇後會附上以下版本、平台、語言與顯示設定,協助排查問題。'; + + @override + String get feedbackDiagnosticVersion => 'App 版本'; + + @override + String get feedbackDiagnosticCommit => '程式版本'; + + @override + String get feedbackDiagnosticPlatform => '平台'; + + @override + String get feedbackDiagnosticLocale => '語言'; + + @override + String get feedbackDiagnosticScreen => '畫面尺寸(邏輯像素)'; + + @override + String get feedbackDiagnosticTextScale => '文字縮放(以 14pt 為基準)'; + + @override + String get feedbackDiagnosticTheme => '顯示主題'; + @override String get feedbackSubmitting => '送出中…'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c91e015a..80bb7d4b 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -848,6 +848,29 @@ "feedbackContentRequired": "請輸入內容", "feedbackContentTooLong": "內文最多 {max} 個字", "@feedbackContentTooLong": {"placeholders": {"max": {"type": "int"}}}, + "feedbackMoreInfo": "更多資訊(選填)", + "feedbackMoreInfoHint": "補充資訊可協助我們了解問題,也可以直接送出。", + "feedbackCategoryLabel": "回饋類型(選填)", + "feedbackCategoryNone": "不指定", + "feedbackCategoryBug": "問題回報", + "feedbackCategoryData": "交通資料錯誤", + "feedbackCategoryFeature": "功能建議", + "feedbackCategoryOther": "其他", + "feedbackRouteContextLabel": "相關路線/站牌(選填)", + "feedbackRouteContextHint": "例如:臺北市 307,往板橋,臺北車站", + "feedbackContactLabel": "聯絡方式(選填)", + "feedbackContactHint": "例如:Email 或 Discord 帳號,方便我們追問細節", + "feedbackOptionalTooLong": "此欄位最多 {max} 個字", + "feedbackIncludeDiagnostics": "附上 App 與顯示資訊", + "feedbackDiagnosticsHint": "選擇後會附上以下版本、平台、語言與顯示設定,協助排查問題。", + "feedbackDiagnosticVersion": "App 版本", + "feedbackDiagnosticCommit": "程式版本", + "feedbackDiagnosticPlatform": "平台", + "feedbackDiagnosticLocale": "語言", + "feedbackDiagnosticScreen": "畫面尺寸(邏輯像素)", + "feedbackDiagnosticTextScale": "文字縮放(以 14pt 為基準)", + "feedbackDiagnosticTheme": "顯示主題", + "@feedbackOptionalTooLong": {"placeholders": {"max": {"type": "int"}}}, "feedbackSubmitting": "送出中…", "feedbackSubmit": "送出回饋", "feedbackInvalidFormat": "送出資料格式不正確。", diff --git a/lib/l10n/app_zh_TW.arb b/lib/l10n/app_zh_TW.arb index f4bc121e..b3ff1cf3 100644 --- a/lib/l10n/app_zh_TW.arb +++ b/lib/l10n/app_zh_TW.arb @@ -801,6 +801,29 @@ "feedbackContentRequired": "請輸入內容", "feedbackContentTooLong": "內文最多 {max} 個字", "@feedbackContentTooLong": {"placeholders": {"max": {"type": "int"}}}, + "feedbackMoreInfo": "更多資訊(選填)", + "feedbackMoreInfoHint": "補充資訊可協助我們了解問題,也可以直接送出。", + "feedbackCategoryLabel": "回饋類型(選填)", + "feedbackCategoryNone": "不指定", + "feedbackCategoryBug": "問題回報", + "feedbackCategoryData": "交通資料錯誤", + "feedbackCategoryFeature": "功能建議", + "feedbackCategoryOther": "其他", + "feedbackRouteContextLabel": "相關路線/站牌(選填)", + "feedbackRouteContextHint": "例如:臺北市 307,往板橋,臺北車站", + "feedbackContactLabel": "聯絡方式(選填)", + "feedbackContactHint": "例如:Email 或 Discord 帳號,方便我們追問細節", + "feedbackOptionalTooLong": "此欄位最多 {max} 個字", + "feedbackIncludeDiagnostics": "附上 App 與顯示資訊", + "feedbackDiagnosticsHint": "選擇後會附上以下版本、平台、語言與顯示設定,協助排查問題。", + "feedbackDiagnosticVersion": "App 版本", + "feedbackDiagnosticCommit": "程式版本", + "feedbackDiagnosticPlatform": "平台", + "feedbackDiagnosticLocale": "語言", + "feedbackDiagnosticScreen": "畫面尺寸(邏輯像素)", + "feedbackDiagnosticTextScale": "文字縮放(以 14pt 為基準)", + "feedbackDiagnosticTheme": "顯示主題", + "@feedbackOptionalTooLong": {"placeholders": {"max": {"type": "int"}}}, "feedbackSubmitting": "送出中…", "feedbackSubmit": "送出回饋", "feedbackInvalidFormat": "送出資料格式不正確。", diff --git a/lib/screens/feedback_screen.dart b/lib/screens/feedback_screen.dart index 6ddd36fb..4a66d033 100644 --- a/lib/screens/feedback_screen.dart +++ b/lib/screens/feedback_screen.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../widgets/ad_banner_widget.dart'; @@ -6,12 +7,16 @@ import '../core/app_controller.dart'; import '../core/app_routes.dart'; import '../core/auth_token_store.dart'; import '../core/feedback_service.dart'; +import '../core/feedback_models.dart'; import '../l10n/app_localizations.dart'; import '../l10n/localized_labels.dart'; import '../widgets/background_image_wrapper.dart'; +import '../widgets/app_dropdown.dart'; class FeedbackScreen extends StatefulWidget { - const FeedbackScreen({super.key}); + const FeedbackScreen({super.key, this.feedbackService}); + + final FeedbackService? feedbackService; @override State createState() => _FeedbackScreenState(); @@ -21,13 +26,20 @@ class _FeedbackScreenState extends State { final _formKey = GlobalKey(); final _titleController = TextEditingController(); final _contentController = TextEditingController(); - final _feedbackService = FeedbackService(); + final _routeContextController = TextEditingController(); + final _contactController = TextEditingController(); + late final _feedbackService = widget.feedbackService ?? FeedbackService(); + FeedbackCategory? _category; + bool _includeDiagnostics = false; bool _submitting = false; @override void dispose() { _titleController.dispose(); _contentController.dispose(); + _routeContextController.dispose(); + _contactController.dispose(); + if (widget.feedbackService == null) _feedbackService.dispose(); super.dispose(); } @@ -51,13 +63,20 @@ class _FeedbackScreenState extends State { await _feedbackService.submitFeedback( title: _titleController.text, content: _contentController.text, + category: _category, + routeContext: _routeContextController.text, + contact: _contactController.text, + diagnostics: _includeDiagnostics ? _diagnostics(controller) : null, ); + if (!mounted) return; _titleController.clear(); _contentController.clear(); + _routeContextController.clear(); + _contactController.clear(); + _category = null; + _includeDiagnostics = false; formState.reset(); - messenger.showSnackBar( - SnackBar(content: Text(l10n.feedbackSubmitted)), - ); + messenger.showSnackBar(SnackBar(content: Text(l10n.feedbackSubmitted))); if (!mounted) { return; } @@ -91,6 +110,120 @@ class _FeedbackScreenState extends State { } } + FeedbackDiagnostics _diagnostics(AppController controller) { + final media = MediaQuery.of(context); + return FeedbackDiagnostics( + appVersion: controller.buildInfo.version, + buildNumber: controller.buildInfo.buildNumber, + commitHash: controller.buildInfo.gitSha, + platform: kIsWeb ? 'web' : defaultTargetPlatform.name.toLowerCase(), + locale: Localizations.localeOf(context).toLanguageTag(), + screenWidth: media.size.width.round(), + screenHeight: media.size.height.round(), + textScale: media.textScaler.scale(14) / 14, + brightness: Theme.of(context).brightness.name, + ); + } + + Widget _optionalDetails(AppController controller, AppLocalizations l10n) { + final diagnostics = _includeDiagnostics ? _diagnostics(controller) : null; + return ExpansionTile( + maintainState: true, + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(bottom: 16), + title: Text(l10n.feedbackMoreInfo), + subtitle: Text(l10n.feedbackMoreInfoHint), + children: [ + AppDropdownFormField( + initialValue: _category?.name ?? '', + decoration: InputDecoration(labelText: l10n.feedbackCategoryLabel), + items: [ + DropdownMenuItem(value: '', child: Text(l10n.feedbackCategoryNone)), + DropdownMenuItem( + value: 'bug', + child: Text(l10n.feedbackCategoryBug), + ), + DropdownMenuItem( + value: 'data', + child: Text(l10n.feedbackCategoryData), + ), + DropdownMenuItem( + value: 'feature', + child: Text(l10n.feedbackCategoryFeature), + ), + DropdownMenuItem( + value: 'other', + child: Text(l10n.feedbackCategoryOther), + ), + ], + onChanged: _submitting + ? null + : (value) { + setState(() { + _category = value == null || value.isEmpty + ? null + : FeedbackCategory.values.byName(value); + }); + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _routeContextController, + enabled: !_submitting, + maxLength: 200, + textInputAction: TextInputAction.next, + decoration: InputDecoration( + labelText: l10n.feedbackRouteContextLabel, + hintText: l10n.feedbackRouteContextHint, + ), + validator: (value) => (value ?? '').trim().length > 200 + ? l10n.feedbackOptionalTooLong(200) + : null, + ), + const SizedBox(height: 8), + TextFormField( + controller: _contactController, + enabled: !_submitting, + maxLength: 200, + decoration: InputDecoration( + labelText: l10n.feedbackContactLabel, + hintText: l10n.feedbackContactHint, + ), + validator: (value) => (value ?? '').trim().length > 200 + ? l10n.feedbackOptionalTooLong(200) + : null, + ), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + value: _includeDiagnostics, + title: Text(l10n.feedbackIncludeDiagnostics), + subtitle: Text(l10n.feedbackDiagnosticsHint), + onChanged: _submitting + ? null + : (value) { + setState(() => _includeDiagnostics = value); + }, + ), + if (diagnostics != null) + Align( + alignment: AlignmentDirectional.centerStart, + child: SelectableText( + [ + '${l10n.feedbackDiagnosticVersion}: ${diagnostics.appVersion}+${diagnostics.buildNumber}', + '${l10n.feedbackDiagnosticCommit}: ${diagnostics.commitHash}', + '${l10n.feedbackDiagnosticPlatform}: ${diagnostics.platform}', + '${l10n.feedbackDiagnosticLocale}: ${diagnostics.locale}', + '${l10n.feedbackDiagnosticScreen}: ${diagnostics.screenWidth} × ${diagnostics.screenHeight}', + '${l10n.feedbackDiagnosticTextScale}: ${diagnostics.textScale.toStringAsFixed(2)}', + '${l10n.feedbackDiagnosticTheme}: ${diagnostics.brightness == 'dark' ? l10n.themeModeDark : l10n.themeModeLight}', + ].join('\n'), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { final controller = AppControllerScope.of(context); @@ -111,26 +244,6 @@ class _FeedbackScreenState extends State { child: ListView( padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), children: [ - // Card( - // child: Padding( - // padding: const EdgeInsets.all(18), - // child: Column( - // crossAxisAlignment: CrossAxisAlignment.start, - // children: [ - // Text( - // '直接把你的想法丟給我們', - // style: Theme.of(context).textTheme.titleMedium, - // ), - // const SizedBox(height: 8), - // const Text( - // '可以回報 bug、提出功能需求,或告訴我們哪裡用起來卡卡的。標題上限 100 字,內文上限 4000 字。', - // ), - // const SizedBox(height: 8), - // const Text('送出前請避免貼上敏感個資、密碼或完整付款資訊。'), - // ], - // ), - // ), - // ), if (!controller.isAuthenticated) ...[ const SizedBox(height: 12), Card( @@ -211,6 +324,8 @@ class _FeedbackScreenState extends State { }, ), const SizedBox(height: 8), + _optionalDetails(controller, l10n), + const SizedBox(height: 8), FilledButton.icon( onPressed: _submitting ? null diff --git a/test/feedback_screen_test.dart b/test/feedback_screen_test.dart new file mode 100644 index 00000000..90c1feea --- /dev/null +++ b/test/feedback_screen_test.dart @@ -0,0 +1,273 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:taiwanbus_flutter/app/bus_app.dart'; +import 'package:taiwanbus_flutter/core/account_sync_service.dart'; +import 'package:taiwanbus_flutter/core/app_analytics.dart'; +import 'package:taiwanbus_flutter/core/app_build_info.dart'; +import 'package:taiwanbus_flutter/core/app_controller.dart'; +import 'package:taiwanbus_flutter/core/app_update_installer.dart'; +import 'package:taiwanbus_flutter/core/app_update_service.dart'; +import 'package:taiwanbus_flutter/core/auth_service.dart'; +import 'package:taiwanbus_flutter/core/bus_repository.dart'; +import 'package:taiwanbus_flutter/core/feedback_service.dart'; +import 'package:taiwanbus_flutter/core/models.dart'; +import 'package:taiwanbus_flutter/core/storage_service.dart'; +import 'package:taiwanbus_flutter/l10n/app_localizations.dart'; +import 'package:taiwanbus_flutter/screens/feedback_screen.dart'; + +const _buildInfo = AppBuildInfo( + version: '1.2.3', + buildNumber: '45', + gitSha: 'abcdef0', + defaultUpdateChannel: AppUpdateChannel.release, +); + +class _Controller extends AppController { + _Controller({this.signedIn = true}) + : super( + repository: BusRepository(), + storage: StorageService(), + analytics: AppAnalytics.deferred(), + buildInfo: _buildInfo, + appUpdateService: AppUpdateService(buildInfo: _buildInfo), + appUpdateInstaller: createAppUpdateInstaller(), + authService: AuthService(), + accountSyncService: AccountSyncService(), + ); + + final bool signedIn; + @override + bool get isAuthenticated => signedIn; +} + +void main() { + Future open( + WidgetTester tester, + FeedbackService service, { + bool signedIn = true, + }) async { + final controller = _Controller(signedIn: signedIn); + addTearDown(controller.dispose); + addTearDown(service.dispose); + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + locale: const Locale('zh', 'TW'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AppControllerScope( + controller: controller, + child: FeedbackScreen(feedbackService: service), + ), + ), + ), + child: const Text('Open'), + ), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + } + + Future fillRequired(WidgetTester tester) async { + await tester.enterText(find.byType(TextFormField).at(0), 'Arrival issue'); + await tester.enterText( + find.byType(TextFormField).at(1), + 'Bus did not arrive', + ); + tester.testTextInput.hide(); + } + + Future expand(WidgetTester tester) async { + await tester.ensureVisible(find.text('更多資訊(選填)')); + await tester.tap(find.text('更多資訊(選填)')); + await tester.pumpAndSettle(); + } + + Future submit(WidgetTester tester) async { + await tester.ensureVisible(find.text('送出回饋')); + await tester.pumpAndSettle(); + await tester.tap(find.text('送出回饋')); + await tester.pumpAndSettle(); + } + + testWidgets('signed-out users still need to sign in', (tester) async { + await open( + tester, + FeedbackService( + client: MockClient((_) async => http.Response('{}', 500)), + ), + signedIn: false, + ); + expect(find.text('請先登入'), findsOneWidget); + expect(find.byType(TextFormField), findsNothing); + }); + + testWidgets('optional information is not required or sent by default', ( + tester, + ) async { + Map? sent; + await open( + tester, + FeedbackService( + client: MockClient((request) async { + sent = jsonDecode(request.body) as Map; + return http.Response('{"feedback_id":"1","created_at":1}', 201); + }), + ), + ); + await fillRequired(tester); + await submit(tester); + expect(sent, {'title': 'Arrival issue', 'content': 'Bus did not arrive'}); + expect(find.text('Open'), findsOneWidget); + }); + + testWidgets('selected details and previewed diagnostics reach the API', ( + tester, + ) async { + Map? sent; + await open( + tester, + FeedbackService( + client: MockClient((request) async { + sent = jsonDecode(request.body) as Map; + return http.Response('{"feedback_id":"1","created_at":1}', 201); + }), + ), + ); + await fillRequired(tester); + await expand(tester); + await tester.ensureVisible(find.byType(DropdownButtonFormField)); + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('交通資料錯誤').last); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).at(2), ' Taipei 307 '); + await tester.enterText( + find.byType(TextFormField).at(3), + ' rider@example.test ', + ); + tester.testTextInput.hide(); + final toggle = find.byType(SwitchListTile); + await tester.ensureVisible(toggle); + expect(tester.widget(toggle).value, isFalse); + await tester.tap(toggle); + await tester.pumpAndSettle(); + expect(find.textContaining('App 版本: 1.2.3+45'), findsOneWidget); + expect(tester.takeException(), isNull); + // Collapsing the section must retain the explicitly chosen values. + await expand(tester); + await submit(tester); + expect(sent!['category'], 'data'); + expect(sent!['route_context'], 'Taipei 307'); + expect(sent!['contact'], 'rider@example.test'); + expect(sent!['diagnostics'], { + 'app_version': '1.2.3', + 'build_number': '45', + 'commit_hash': 'abcdef0', + 'platform': 'android', + 'locale': 'zh-TW', + 'screen_width': 390, + 'screen_height': 844, + 'text_scale': 1.0, + 'brightness': 'light', + }); + }); + + testWidgets('turning diagnostics off removes it from submission', ( + tester, + ) async { + Map? sent; + await open( + tester, + FeedbackService( + client: MockClient((request) async { + sent = jsonDecode(request.body) as Map; + return http.Response('{"feedback_id":"1","created_at":1}', 201); + }), + ), + ); + await fillRequired(tester); + await expand(tester); + final toggle = find.byType(SwitchListTile); + await tester.ensureVisible(toggle); + await tester.tap(toggle); + await tester.pumpAndSettle(); + await tester.tap(toggle); + await tester.pumpAndSettle(); + expect(find.byType(SelectableText), findsNothing); + await submit(tester); + expect(sent!.containsKey('diagnostics'), isFalse); + }); + + testWidgets('failed submissions retain optional input for retry', ( + tester, + ) async { + var attempts = 0; + await open( + tester, + FeedbackService( + client: MockClient((request) async { + attempts++; + expect(jsonDecode(request.body)['contact'], 'rider@example.test'); + return attempts == 1 + ? http.Response('{}', 500) + : http.Response('{"feedback_id":"1","created_at":1}', 201); + }), + ), + ); + await fillRequired(tester); + await expand(tester); + await tester.enterText( + find.byType(TextFormField).at(3), + 'rider@example.test', + ); + tester.testTextInput.hide(); + await submit(tester); + expect(find.text('rider@example.test'), findsOneWidget); + await submit(tester); + expect(attempts, 2); + expect(find.text('Open'), findsOneWidget); + }); + + testWidgets( + 'leaving during submission does not access disposed form fields', + (tester) async { + final pending = Completer(); + await open( + tester, + FeedbackService(client: MockClient((_) => pending.future)), + ); + await fillRequired(tester); + await tester.ensureVisible(find.text('送出回饋')); + await tester.tap(find.text('送出回饋')); + await tester.pump(); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + pending.complete( + http.Response('{"feedback_id":"1","created_at":1}', 201), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/test/feedback_service_test.dart b/test/feedback_service_test.dart index edf843f6..e1c0e9cd 100644 --- a/test/feedback_service_test.dart +++ b/test/feedback_service_test.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:taiwanbus_flutter/core/auth_token_store.dart'; import 'package:taiwanbus_flutter/core/feedback_service.dart'; +import 'package:taiwanbus_flutter/core/feedback_models.dart'; void main() { tearDown(() { @@ -24,6 +25,7 @@ void main() { final body = jsonDecode(request.body) as Map; expect(body['title'], 'Bug report'); expect(body['content'], 'Something broke'); + expect(body.keys, unorderedEquals(['title', 'content'])); return http.Response( jsonEncode({ 'ok': true, @@ -44,6 +46,105 @@ void main() { expect(result.createdAt, 1716000000); }); + test( + 'submitFeedback sends trimmed optional details and explicit diagnostics', + () async { + const diagnostics = FeedbackDiagnostics( + appVersion: '1.2.3', + buildNumber: '45', + commitHash: 'abcdef0', + platform: 'web', + locale: 'zh-TW', + screenWidth: 390, + screenHeight: 844, + textScale: 1.2, + brightness: 'dark', + ); + final service = FeedbackService( + client: MockClient((request) async { + expect(jsonDecode(request.body), { + 'title': 'Bug', + 'content': 'Details', + 'category': 'data', + 'route_context': 'Taipei 307', + 'contact': 'user@example.test', + 'diagnostics': { + 'app_version': '1.2.3', + 'build_number': '45', + 'commit_hash': 'abcdef0', + 'platform': 'web', + 'locale': 'zh-TW', + 'screen_width': 390, + 'screen_height': 844, + 'text_scale': 1.2, + 'brightness': 'dark', + }, + }); + return http.Response('{"feedback_id":"1","created_at":1}', 201); + }), + ); + addTearDown(service.dispose); + await service.submitFeedback( + title: ' Bug ', + content: ' Details ', + category: FeedbackCategory.data, + routeContext: ' Taipei 307 ', + contact: ' user@example.test ', + diagnostics: diagnostics, + ); + }, + ); + + test('blank optional fields are omitted', () async { + final service = FeedbackService( + client: MockClient((request) async { + expect(jsonDecode(request.body), {'title': 'A', 'content': 'B'}); + return http.Response('{"feedback_id":"1","created_at":1}', 201); + }), + ); + addTearDown(service.dispose); + await service.submitFeedback( + title: 'A', + content: 'B', + routeContext: ' ', + contact: '\n', + ); + }); + + test( + 'optional fields reject overlong values before making a request', + () async { + var requests = 0; + final service = FeedbackService( + client: MockClient((request) async { + requests++; + return http.Response('{}', 201); + }), + ); + addTearDown(service.dispose); + await expectLater( + service.submitFeedback( + title: 'A', + content: 'B', + routeContext: 'x' * 201, + ), + throwsArgumentError, + ); + await expectLater( + service.submitFeedback(title: 'A', content: 'B', contact: 'x' * 201), + throwsArgumentError, + ); + expect(requests, 0); + await service.submitFeedback( + title: 'A', + content: 'B', + routeContext: 'x' * 200, + contact: 'x' * 200, + ); + expect(requests, 1); + }, + ); + test('submitFeedback surfaces rate-limit details', () async { final service = FeedbackService( client: MockClient((request) async {