From a9b8adb6b354a03c90bd9e3660365f484e087176 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Fri, 23 Oct 2026 19:12:56 +0300 Subject: [PATCH 1/4] feat: update About settings and add in-app support --- README.md | 2 + app/lib/config/app_router.dart | 14 + app/lib/pages/licenses_page.dart | 215 +++++++++++++ app/lib/pages/support_page.dart | 283 ++++++++++++++++++ app/lib/services/about_links.dart | 38 +++ app/lib/services/app_info.dart | 6 + app/lib/services/app_info_base_url_stub.dart | 1 + app/lib/services/app_info_base_url_web.dart | 3 + app/lib/services/support_api_client.dart | 99 ++++++ app/lib/services/support_attachment.dart | 95 ++++++ .../profile/about_settings_section.dart | 84 ++++-- .../widgets/profile/support_confirmation.dart | 80 +++++ .../Flutter/GeneratedPluginRegistrant.swift | 2 + app/pubspec.lock | 18 +- app/pubspec.yaml | 2 + app/test/helpers/support_file_picker.dart | 21 ++ app/test/pages/licenses_page_test.dart | 55 ++++ app/test/pages/support_page_test.dart | 170 +++++++++++ .../services/support_api_client_test.dart | 122 ++++++++ .../services/support_attachment_test.dart | 51 ++++ .../profile/about_settings_section_test.dart | 87 ++++++ .../profile/support_confirmation_test.dart | 56 ++++ docs/about-and-support.md | 16 + 23 files changed, 1487 insertions(+), 33 deletions(-) create mode 100644 app/lib/pages/licenses_page.dart create mode 100644 app/lib/pages/support_page.dart create mode 100644 app/lib/services/about_links.dart create mode 100644 app/lib/services/app_info.dart create mode 100644 app/lib/services/app_info_base_url_stub.dart create mode 100644 app/lib/services/app_info_base_url_web.dart create mode 100644 app/lib/services/support_api_client.dart create mode 100644 app/lib/services/support_attachment.dart create mode 100644 app/lib/widgets/profile/support_confirmation.dart create mode 100644 app/test/helpers/support_file_picker.dart create mode 100644 app/test/pages/licenses_page_test.dart create mode 100644 app/test/pages/support_page_test.dart create mode 100644 app/test/services/support_api_client_test.dart create mode 100644 app/test/services/support_attachment_test.dart create mode 100644 app/test/widgets/profile/about_settings_section_test.dart create mode 100644 app/test/widgets/profile/support_confirmation_test.dart create mode 100644 docs/about-and-support.md diff --git a/README.md b/README.md index 97425929..0c8db277 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,8 @@ Keep actionable bug reports and technical decisions in GitHub issues and pull re | [docs](https://github.com/PapyrusReader/docs) | Documentation | | [papyrus](https://github.com/PapyrusReader/papyrus) | Development workspace | +See [About and support](docs/about-and-support.md) for release notes, licenses, and feedback. + ## Google Play testing builds See [the release guide](docs/RELEASING.md) for signed Android App Bundles, diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index a6e2d238..b53377e4 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -16,6 +16,9 @@ import 'package:papyrus/pages/forgot_password_page.dart'; import 'package:papyrus/pages/login_page.dart'; import 'package:papyrus/pages/edit_profile_page.dart'; import 'package:papyrus/pages/profile_page.dart'; +import 'package:papyrus/pages/licenses_page.dart'; +import 'package:papyrus/pages/support_page.dart'; +import 'package:papyrus/services/support_api_client.dart'; import 'package:papyrus/pages/register_page.dart'; import 'package:papyrus/pages/reader_page.dart'; import 'package:papyrus/pages/search_options_page.dart'; @@ -268,6 +271,17 @@ class AppRouter { path: '/profile', pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const ProfilePage()), routes: [ + GoRoute( + path: 'licenses', + pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const LicensesPage()), + ), + GoRoute( + path: 'support', + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: SupportPage(client: SupportApiClient(config: syncSettingsProvider.activeApiConfig)), + ), + ), GoRoute( name: 'EDIT_PROFILE', path: 'edit', diff --git a/app/lib/pages/licenses_page.dart b/app/lib/pages/licenses_page.dart new file mode 100644 index 00000000..a0ada2fb --- /dev/null +++ b/app/lib/pages/licenses_page.dart @@ -0,0 +1,215 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:papyrus/themes/app_motion.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/library/library_page_header.dart'; +import 'package:papyrus/widgets/shared/app_motion_control.dart'; + +class LicensesPage extends StatefulWidget { + const LicensesPage({super.key}); + + @override + State createState() => _LicensesPageState(); +} + +class _LicensesPageState extends State { + late Future>> _licenses = _loadLicenses(); + String? _selectedPackage; + + Future>> _loadLicenses() async { + final packages = >{}; + + await for (final entry in LicenseRegistry.licenses) { + for (final package in entry.packages) { + (packages[package] ??= []).add(entry); + } + } + + return packages; + } + + @override + Widget build(BuildContext context) { + return _LicenseMotionScope( + child: _LicenseScaffold( + title: 'Licenses', + child: FutureBuilder>>( + future: _licenses, + builder: (context, snapshot) { + if (snapshot.hasError) { + return Center( + child: TextButton( + onPressed: () => setState(() => _licenses = _loadLicenses()), + child: const Text('Could not load licenses. Retry'), + ), + ); + } + + final packages = snapshot.data; + + if (packages == null) { + return const Center(child: Text('Loading licenses…')); + } + + if (packages.isEmpty) { + return const Center(child: Text('No licenses registered.')); + } + + final names = packages.keys.toList() + ..sort((left, right) => left.toLowerCase().compareTo(right.toLowerCase())); + final selected = _selectedPackage ?? names.first; + + return LayoutBuilder( + builder: (context, constraints) { + final wide = constraints.maxWidth >= Breakpoints.tablet; + + final list = ListView.builder( + key: const PageStorageKey('license-packages'), + padding: const EdgeInsets.all(Spacing.md), + itemCount: names.length, + itemBuilder: (context, index) { + final name = names[index]; + final entries = packages[name]!; + + return AppMotionControl( + value: wide && name == selected, + builder: (focusNode) => ListTile( + focusNode: focusNode, + title: Text(name), + subtitle: Text('${entries.length} ${entries.length == 1 ? 'license' : 'licenses'}'), + selected: wide && name == selected, + selectedTileColor: Theme.of(context).colorScheme.secondaryContainer, + selectedColor: Theme.of(context).colorScheme.onSecondaryContainer, + onTap: () { + setState(() => _selectedPackage = name); + + if (!wide) { + Navigator.of(context).push( + PageRouteBuilder( + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (context, animation, secondaryAnimation) => _LicenseMotionScope( + child: _LicenseScaffold( + title: name, + child: _LicenseDetails(package: name, entries: entries), + ), + ), + ), + ); + } + }, + ), + ); + }, + ); + + if (!wide) { + return list; + } + + return Row( + children: [ + SizedBox(width: 280, child: list), + VerticalDivider(width: 1, color: Theme.of(context).colorScheme.outline), + Expanded( + child: _LicenseDetails(package: selected, entries: packages[selected]!), + ), + ], + ); + }, + ); + }, + ), + ), + ); + } +} + +class _LicenseScaffold extends StatelessWidget { + const _LicenseScaffold({required this.title, required this.child}); + + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + final compact = MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; + + return Scaffold( + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LibraryPageHeader(title: title, leading: const BackButton(), compact: compact), + if (compact) const Divider(height: 1), + Expanded(child: child), + ], + ), + ), + ); + } +} + +class _LicenseMotionScope extends StatelessWidget { + const _LicenseMotionScope({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) => AppMotionScope( + reduceAnimations: true, + child: Theme( + data: Theme.of(context).copyWith(splashFactory: NoSplash.splashFactory, highlightColor: Colors.transparent), + child: child, + ), + ); +} + +class _LicenseDetails extends StatelessWidget { + const _LicenseDetails({required this.package, required this.entries}); + + final String package; + final List entries; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + return SingleChildScrollView( + key: ValueKey(package), + padding: const EdgeInsets.all(Spacing.lg), + child: Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 900), + child: SelectionArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(package, style: textTheme.titleLarge), + const SizedBox(height: Spacing.lg), + for (var index = 0; index < entries.length; index++) ...[ + if (index > 0) Divider(height: Spacing.xl, color: Theme.of(context).colorScheme.outline), + for (final paragraph in entries[index].paragraphs) + Padding( + padding: EdgeInsets.only( + left: paragraph.indent > 0 ? paragraph.indent.clamp(0, 4) * Spacing.md : 0, + bottom: Spacing.sm, + ), + child: Text( + paragraph.text, + textAlign: paragraph.indent == LicenseParagraph.centeredIndent + ? TextAlign.center + : TextAlign.start, + style: textTheme.bodyMedium, + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} diff --git a/app/lib/pages/support_page.dart b/app/lib/pages/support_page.dart new file mode 100644 index 00000000..fef3d450 --- /dev/null +++ b/app/lib/pages/support_page.dart @@ -0,0 +1,283 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:papyrus/services/about_links.dart'; +import 'package:papyrus/services/app_info.dart'; +import 'package:papyrus/services/support_api_client.dart'; +import 'package:papyrus/services/support_attachment.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/library/library_page_header.dart'; +import 'package:papyrus/widgets/profile/support_confirmation.dart'; + +class SupportPage extends StatefulWidget { + const SupportPage({super.key, required this.client}); + + final SupportApiClient client; + + @override + State createState() => _SupportPageState(); +} + +class _SupportPageState extends State { + final _scrollController = ScrollController(); + final _formKey = GlobalKey(); + final _title = TextEditingController(); + final _description = TextEditingController(); + String _kind = 'bug'; + String _version = 'Unavailable'; + final _attachments = []; + bool _picking = false; + String? _attachmentError; + bool _sending = false; + String? _error; + SupportIssue? _issue; + + String get _platform => kIsWeb ? 'Web (${defaultTargetPlatform.name})' : defaultTargetPlatform.name; + + @override + void initState() { + super.initState(); + _loadVersion(); + } + + Future _loadVersion() async { + try { + final info = await loadAppInfo(); + + if (mounted) { + setState(() => _version = AboutLinks.versionLabel(info)); + } + } catch (_) { + // A missing version must not prevent the reader from reporting a problem. + } + } + + @override + void dispose() { + _scrollController.dispose(); + _title.dispose(); + _description.dispose(); + widget.client.close(); + super.dispose(); + } + + Future _pickAttachments() async { + if (_picking || _sending) { + return; + } + + setState(() { + _picking = true; + _attachmentError = null; + }); + + try { + final attachments = await SupportAttachment.pick(SupportAttachment.maxCount - _attachments.length); + + if (mounted) { + setState(() => _attachments.addAll(attachments)); + } + } catch (error) { + if (mounted) { + setState(() { + _attachmentError = error is FormatException ? error.message : 'Could not attach files.'; + }); + } + } finally { + if (mounted) { + setState(() => _picking = false); + } + } + } + + Future _submit() async { + if (_sending || _picking || !_formKey.currentState!.validate()) { + return; + } + + setState(() { + _sending = true; + _error = null; + }); + + try { + final issue = await widget.client.submit( + kind: _kind, + title: _title.text, + description: _description.text, + appVersion: _version, + platform: _platform, + attachments: _attachments, + ); + + if (mounted) { + FocusScope.of(context).unfocus(); + + setState(() { + _issue = issue; + _attachments.clear(); + }); + + if (_scrollController.hasClients) { + _scrollController.jumpTo(0); + } + } + } on SupportException { + if (mounted) { + setState(() => _error = 'Submission failed.'); + } + } finally { + if (mounted) { + setState(() => _sending = false); + } + } + } + + @override + Widget build(BuildContext context) { + final issue = _issue; + final textTheme = Theme.of(context).textTheme; + final compact = MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; + + return PopScope( + canPop: !_sending, + child: Scaffold( + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LibraryPageHeader(title: 'Support', leading: const BackButton(), compact: compact), + if (compact) const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(Spacing.lg), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: issue != null + ? SupportConfirmation(issue: issue) + : Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('How can we help?', style: textTheme.titleLarge), + const SizedBox(height: Spacing.md), + const Text( + 'Report a problem or suggest an improvement. Feedback and attachments are public. ' + 'Do not include passwords, private book content, or other personal information.', + ), + const SizedBox(height: Spacing.lg), + DropdownButtonFormField( + initialValue: _kind, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Request type'), + items: const [ + DropdownMenuItem(value: 'bug', child: Text('Bug report')), + DropdownMenuItem(value: 'feature', child: Text('Feature request')), + ], + onChanged: _sending ? null : (value) => setState(() => _kind = value!), + ), + const SizedBox(height: Spacing.md), + TextFormField( + controller: _title, + enabled: !_sending, + maxLength: 150, + decoration: const InputDecoration(labelText: 'Title'), + validator: (value) => + value == null || value.trim().isEmpty ? 'Enter a title.' : null, + ), + const SizedBox(height: Spacing.md), + TextFormField( + controller: _description, + enabled: !_sending, + minLines: 6, + maxLines: 12, + maxLength: 8000, + decoration: InputDecoration( + labelText: 'Description', + alignLabelWithHint: true, + hintText: _kind == 'bug' + ? 'What happened? What did you expect? How can we reproduce it?' + : 'What would you like to do, and how would it help?', + ), + validator: (value) => value == null || value.trim().length < 10 + ? 'Please add a little more detail (at least 10 characters).' + : null, + ), + const SizedBox(height: Spacing.md), + OutlinedButton.icon( + onPressed: _sending || _picking || _attachments.length >= SupportAttachment.maxCount + ? null + : _pickAttachments, + icon: const Icon(Icons.attach_file), + label: Text(_picking ? 'Attaching…' : 'Attach images or videos'), + ), + const SizedBox(height: Spacing.xs), + Text('Up to 5 files · 10 MB each', style: textTheme.bodySmall), + for (final attachment in _attachments) + Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Row( + children: [ + Icon(attachment.isVideo ? Icons.videocam_outlined : Icons.image_outlined), + const SizedBox(width: Spacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(attachment.name, maxLines: 2, overflow: TextOverflow.ellipsis), + Text(attachment.sizeLabel, style: textTheme.bodySmall), + ], + ), + ), + IconButton( + tooltip: 'Remove ${attachment.name}', + onPressed: _sending || _picking + ? null + : () => setState(() => _attachments.remove(attachment)), + icon: const Icon(Icons.close), + ), + ], + ), + ), + if (_attachmentError != null) ...[ + const SizedBox(height: Spacing.sm), + Semantics( + liveRegion: true, + child: Text( + _attachmentError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + if (_error != null) ...[ + const SizedBox(height: Spacing.sm), + Semantics( + liveRegion: true, + child: Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ], + const SizedBox(height: Spacing.lg), + FilledButton( + onPressed: _sending || _picking ? null : _submit, + child: Text(_sending ? 'Submitting…' : 'Submit'), + ), + ], + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/services/about_links.dart b/app/lib/services/about_links.dart new file mode 100644 index 00000000..8e110497 --- /dev/null +++ b/app/lib/services/about_links.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class AboutLinks { + static final repository = Uri.https('github.com', '/PapyrusReader/client'); + static final issues = Uri.https('github.com', '/PapyrusReader/client/issues'); + static final releases = Uri.https('github.com', '/PapyrusReader/client/releases'); + + static Uri release(PackageInfo info) { + if (info.version.isEmpty || info.buildNumber.isEmpty) { + return releases; + } + + return repository.replace( + pathSegments: ['PapyrusReader', 'client', 'releases', 'tag', 'v${info.version}+${info.buildNumber}'], + ); + } + + static String versionLabel(PackageInfo info) => + info.buildNumber.isEmpty ? info.version : '${info.version} (${info.buildNumber})'; + + static Future open(BuildContext context, Uri uri) async { + try { + if (await launchUrl(uri, mode: LaunchMode.externalApplication)) { + return; + } + } catch (_) { + // The browser can reject the launch even when the platform supports HTTPS. + } + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not open $uri. Please try again.')), + ); + } + } +} diff --git a/app/lib/services/app_info.dart b/app/lib/services/app_info.dart new file mode 100644 index 00000000..1766cda0 --- /dev/null +++ b/app/lib/services/app_info.dart @@ -0,0 +1,6 @@ +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:papyrus/services/app_info_base_url_stub.dart' + if (dart.library.js_interop) 'package:papyrus/services/app_info_base_url_web.dart'; + +// Use the document's base URL on web, including when opened on a nested route. +Future loadAppInfo() => PackageInfo.fromPlatform(baseUrl: appInfoBaseUrl); diff --git a/app/lib/services/app_info_base_url_stub.dart b/app/lib/services/app_info_base_url_stub.dart new file mode 100644 index 00000000..5edbcba7 --- /dev/null +++ b/app/lib/services/app_info_base_url_stub.dart @@ -0,0 +1 @@ +String? get appInfoBaseUrl => null; diff --git a/app/lib/services/app_info_base_url_web.dart b/app/lib/services/app_info_base_url_web.dart new file mode 100644 index 00000000..db223a6f --- /dev/null +++ b/app/lib/services/app_info_base_url_web.dart @@ -0,0 +1,3 @@ +import 'package:web/web.dart' as web; + +String get appInfoBaseUrl => web.document.baseURI; diff --git a/app/lib/services/support_api_client.dart b/app/lib/services/support_api_client.dart new file mode 100644 index 00000000..ff8d9383 --- /dev/null +++ b/app/lib/services/support_api_client.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http_parser/http_parser.dart'; +import 'package:papyrus/services/support_attachment.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; + +class SupportException implements Exception { + const SupportException(this.message); + + final String message; + + @override + String toString() => message; +} + +class SupportIssue { + const SupportIssue({required this.number, required this.url}); + + final int number; + final Uri url; +} + +class SupportApiClient { + SupportApiClient({required this.config, http.Client? httpClient}) : _client = httpClient ?? http.Client(); + + final PapyrusApiConfig config; + final http.Client _client; + + Future submit({ + required String kind, + required String title, + required String description, + required String appVersion, + required String platform, + List attachments = const [], + }) async { + try { + final request = http.MultipartRequest('POST', config.endpoint('/support/issues')); + request.headers['Accept'] = 'application/json'; + request.fields['payload'] = jsonEncode({ + 'kind': kind, + 'title': title.trim(), + 'description': description.trim(), + 'app_version': appVersion, + 'platform': platform, + }); + + for (final attachment in attachments) { + request.files.add( + http.MultipartFile.fromBytes( + 'attachments', + attachment.bytes, + filename: attachment.name, + contentType: MediaType.parse(attachment.contentType), + ), + ); + } + + final response = await _client.send(request).then(http.Response.fromStream).timeout(const Duration(minutes: 4)); + + if (response.statusCode != 201) { + final message = switch (response.statusCode) { + 429 => 'Too many submissions. Please try again later.', + 503 => 'Support submissions are not configured on this server.', + 404 => 'This server does not support in-app submissions yet.', + 422 => 'Please check your title and description.', + 504 => 'Submission could not be confirmed. Check the repository before submitting again.', + _ => 'Could not submit your request. Please try again later.', + }; + + throw SupportException(message); + } + + final result = jsonDecode(response.body) as Map; + final number = result['number'] as int; + final url = Uri.parse(result['url'] as String); + + final validIssueUrl = RegExp( + r'^https://github\.com/[A-Za-z0-9-]+/[A-Za-z0-9][A-Za-z0-9_.-]*/issues/[1-9][0-9]*$', + ).hasMatch(url.toString()); + + if (number <= 0 || !validIssueUrl || url.pathSegments.last != number.toString()) { + throw const FormatException('Invalid issue response'); + } + + return SupportIssue(number: number, url: url); + } on SupportException { + rethrow; + } catch (_) { + throw const SupportException( + 'Submission could not be confirmed. Check your connection and the repository before submitting again.', + ); + } + } + + void close() => _client.close(); +} diff --git a/app/lib/services/support_attachment.dart b/app/lib/services/support_attachment.dart new file mode 100644 index 00000000..536a91b8 --- /dev/null +++ b/app/lib/services/support_attachment.dart @@ -0,0 +1,95 @@ +import 'dart:typed_data'; + +import 'package:file_picker/file_picker.dart'; + +class SupportAttachment { + const SupportAttachment({required this.name, required this.bytes, required this.contentType}); + + static const maxCount = 5; + static const maxBytes = 10 * 1024 * 1024; + static const mediaTypes = { + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'mp4': 'video/mp4', + 'mov': 'video/quicktime', + 'webm': 'video/webm', + }; + + final String name; + final Uint8List bytes; + final String contentType; + + bool get isVideo => contentType.startsWith('video/'); + String get sizeLabel { + if (bytes.length < 1024) { + return '${bytes.length} B'; + } + + if (bytes.length < 1024 * 1024) { + return '${(bytes.length / 1024).toStringAsFixed(1)} KB'; + } + + return '${(bytes.length / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + + static Future> pick(int remaining) async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: mediaTypes.keys.toList(), + allowMultiple: true, + allowCompression: false, + withData: false, + withReadStream: true, + ); + + if (result == null) { + return []; + } + + if (result.files.length > remaining) { + throw const FormatException('Attach up to five files.'); + } + + final attachments = []; + + for (final file in result.files) { + final contentType = mediaTypes[file.extension?.toLowerCase()]; + + if (contentType == null) { + throw const FormatException('Choose a PNG, JPG, GIF, WebP, MP4, MOV, or WebM file.'); + } + + if (file.size <= 0 || file.size > maxBytes) { + throw const FormatException('Each attachment must be no larger than 10 MB and cannot be empty.'); + } + + final builder = BytesBuilder(copy: false); + final stream = file.readStream; + + if (stream != null) { + await for (final chunk in stream) { + if (builder.length + chunk.length > maxBytes) { + throw const FormatException('Each attachment must be no larger than 10 MB.'); + } + + builder.add(chunk); + } + } else if (file.bytes != null) { + builder.add(file.bytes!); + } else { + throw const FormatException('Could not read the selected file.'); + } + + if (builder.isEmpty || builder.length > maxBytes) { + throw const FormatException('Each attachment must be no larger than 10 MB and cannot be empty.'); + } + + attachments.add(SupportAttachment(name: file.name, bytes: builder.takeBytes(), contentType: contentType)); + } + + return attachments; + } +} diff --git a/app/lib/widgets/profile/about_settings_section.dart b/app/lib/widgets/profile/about_settings_section.dart index f9212f41..c6a5c1cc 100644 --- a/app/lib/widgets/profile/about_settings_section.dart +++ b/app/lib/widgets/profile/about_settings_section.dart @@ -1,49 +1,69 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:papyrus/services/about_links.dart'; +import 'package:papyrus/services/app_info.dart'; +import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/settings/settings_row.dart'; import 'package:papyrus/widgets/settings/settings_section.dart'; -/// About settings section for ProfilePage. -class AboutSettingsSection extends StatelessWidget { +class AboutSettingsSection extends StatefulWidget { final bool isDesktop; const AboutSettingsSection({super.key, this.isDesktop = false}); - static void showLicenses(BuildContext context) { - showLicensePage(context: context, applicationName: 'Papyrus', applicationVersion: '1.0.0'); - } + @override + State createState() => _AboutSettingsSectionState(); +} + +class _AboutSettingsSectionState extends State { + late final Future _packageInfo = loadAppInfo(); @override Widget build(BuildContext context) { - if (isDesktop) { - return _buildDesktop(context); - } + return FutureBuilder( + future: _packageInfo, + builder: (context, snapshot) { + final info = snapshot.data; + final version = info == null ? (snapshot.hasError ? 'Unavailable' : 'Loading…') : AboutLinks.versionLabel(info); - return _buildMobile(context); - } + final externalLinkIndicator = Tooltip( + message: 'Opens in a new browser tab', + child: Icon( + Icons.open_in_new, + color: Theme.of(context).colorScheme.onSurfaceVariant, + size: IconSizes.medium, + ), + ); - Widget _buildMobile(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SettingsSectionHeader(title: 'About'), - const SettingsRow(label: 'Version', value: '1.0.0', showChevron: false), - SettingsRow(label: 'Licenses', onTap: () => showLicenses(context)), - SettingsRow(label: 'Support', onTap: () {}), - SettingsRow(label: "What's new", onTap: () {}), - SettingsRow(label: 'GitHub repository', onTap: () {}), - ], - ); - } + final rows = [ + SettingsRow(label: 'Version', value: version, showChevron: false), + SettingsRow(label: 'Licenses', onTap: () => context.push('/profile/licenses')), + SettingsRow(label: 'Support', onTap: () => context.push('/profile/support')), + SettingsRow( + label: "What's new", + trailing: externalLinkIndicator, + onTap: () => AboutLinks.open(context, info == null ? AboutLinks.releases : AboutLinks.release(info)), + ), + SettingsRow( + label: 'GitHub repository', + trailing: externalLinkIndicator, + onTap: () => AboutLinks.open(context, AboutLinks.repository), + ), + ]; + + if (widget.isDesktop) { + return SettingsCard(children: rows); + } - Widget _buildDesktop(BuildContext context) { - return SettingsCard( - children: [ - const SettingsRow(label: 'Version', value: '1.0.0', showChevron: false), - SettingsRow(label: "What's new", onTap: () {}), - SettingsRow(label: 'Licenses', onTap: () => showLicenses(context)), - SettingsRow(label: 'Support', onTap: () {}), - SettingsRow(label: 'GitHub repository', onTap: () {}), - ], + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SettingsSectionHeader(title: 'About'), + ...rows, + ], + ); + }, ); } } diff --git a/app/lib/widgets/profile/support_confirmation.dart b/app/lib/widgets/profile/support_confirmation.dart new file mode 100644 index 00000000..0491cf75 --- /dev/null +++ b/app/lib/widgets/profile/support_confirmation.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:papyrus/services/about_links.dart'; +import 'package:papyrus/services/support_api_client.dart'; +import 'package:papyrus/themes/design_tokens.dart'; +import 'package:papyrus/widgets/settings/settings_section.dart'; + +class SupportConfirmation extends StatelessWidget { + const SupportConfirmation({super.key, required this.issue}); + + final SupportIssue issue; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final textTheme = theme.textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Semantics( + header: true, + liveRegion: true, + child: Text('Thank you for helping us improve.', style: textTheme.titleLarge), + ), + const SizedBox(height: Spacing.md), + Text( + 'Your report has been added to our queue. You can follow updates and join the conversation on GitHub.', + style: textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant), + ), + const SizedBox(height: Spacing.lg), + SettingsCard( + children: [ + LayoutBuilder( + builder: (context, constraints) { + final stackAction = constraints.maxWidth < 400 || MediaQuery.textScalerOf(context).scale(16) > 24; + final reference = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('GitHub issue', style: textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant)), + const SizedBox(height: Spacing.xs), + Text('#${issue.number}', style: textTheme.titleMedium), + ], + ); + final action = Tooltip( + message: 'Opens in a new browser tab', + child: TextButton.icon( + onPressed: () => AboutLinks.open(context, issue.url), + icon: const Icon(Icons.open_in_new, size: IconSizes.small), + iconAlignment: IconAlignment.end, + label: const Text('View issue'), + ), + ); + + if (stackAction) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + reference, + const SizedBox(height: Spacing.sm), + action, + ], + ); + } + + return Row( + children: [ + Expanded(child: reference), + const SizedBox(width: Spacing.md), + action, + ], + ); + }, + ), + ], + ), + ], + ); + } +} diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift index 65cf75b9..95600718 100644 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -12,6 +12,7 @@ import flutter_secure_storage_darwin import flutter_timezone import flutter_web_auth_2 import mobile_scanner +import package_info_plus import pdfium_flutter import shared_preferences_foundation import sqflite_darwin @@ -26,6 +27,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) FlutterWebAuth2Plugin.register(with: registry.registrar(forPlugin: "FlutterWebAuth2Plugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PDFiumFlutterPlugin.register(with: registry.registrar(forPlugin: "PDFiumFlutterPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) diff --git a/app/pubspec.lock b/app/pubspec.lock index 694ab95b..a3f3f903 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -559,6 +559,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + url: "https://pub.dev" + source: hosted + version: "8.3.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" papyrus_reader: dependency: "direct main" description: @@ -1021,7 +1037,7 @@ packages: source: path version: "1.1.0" url_launcher: - dependency: transitive + dependency: "direct main" description: name: url_launcher sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 421592dc..e0ca4d2b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -48,6 +48,8 @@ dependencies: uuid: ^4.5.1 timezone: ^0.11.1 flutter_timezone: ^5.1.1 + package_info_plus: ^8.3.1 + url_launcher: ^6.3.2 dev_dependencies: flutter_test: diff --git a/app/test/helpers/support_file_picker.dart b/app/test/helpers/support_file_picker.dart new file mode 100644 index 00000000..a8488d0e --- /dev/null +++ b/app/test/helpers/support_file_picker.dart @@ -0,0 +1,21 @@ +import 'package:file_picker/file_picker.dart'; + +class SupportFilePicker extends FilePicker { + FilePickerResult? result; + + @override + Future pickFiles({ + String? dialogTitle, + String? initialDirectory, + FileType type = FileType.any, + List? allowedExtensions, + Function(FilePickerStatus)? onFileLoading, + bool allowCompression = true, + int compressionQuality = 30, + bool allowMultiple = false, + bool withData = false, + bool withReadStream = false, + bool lockParentWindow = false, + bool readSequential = false, + }) async => result; +} diff --git a/app/test/pages/licenses_page_test.dart b/app/test/pages/licenses_page_test.dart new file mode 100644 index 00000000..0b76ca05 --- /dev/null +++ b/app/test/pages/licenses_page_test.dart @@ -0,0 +1,55 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/pages/licenses_page.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/library/library_page_header.dart'; + +void main() { + setUp(() { + LicenseRegistry.reset(); + LicenseRegistry.addLicense( + () => Stream.fromIterable([ + const LicenseEntryWithLineBreaks(['alpha', 'shared'], 'Alpha license text'), + const LicenseEntryWithLineBreaks(['beta', 'shared'], 'Beta license text'), + ]), + ); + }); + tearDown(LicenseRegistry.reset); + + testWidgets('wide licenses show all notices and switch without animation', (tester) async { + tester.view.physicalSize = const Size(1200, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp(theme: AppTheme.dark, home: const LicensesPage())); + await tester.pumpAndSettle(); + expect(find.byType(LibraryPageHeader), findsOneWidget); + expect(find.text('Alpha license text'), findsOneWidget); + await tester.tap(find.text('shared')); + await tester.pump(); + expect(find.text('Alpha license text'), findsOneWidget); + expect(find.text('Beta license text'), findsOneWidget); + expect(tester.hasRunningAnimations, isFalse); + expect(tester.takeException(), isNull); + }); + + testWidgets('compact license navigation and back are immediate', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(MaterialApp(theme: AppTheme.eink, home: const LicensesPage())); + await tester.pumpAndSettle(); + await tester.tap(find.text('beta')); + await tester.pump(); + expect(find.text('Beta license text'), findsOneWidget); + expect(tester.hasRunningAnimations, isFalse); + await tester.pageBack(); + await tester.pump(); + expect(find.text('Licenses'), findsOneWidget); + expect(find.text('Beta license text'), findsNothing); + expect(tester.hasRunningAnimations, isFalse); + expect(tester.takeException(), isNull); + }); +} diff --git a/app/test/pages/support_page_test.dart b/app/test/pages/support_page_test.dart new file mode 100644 index 00000000..9873ed86 --- /dev/null +++ b/app/test/pages/support_page_test.dart @@ -0,0 +1,170 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:file_picker/file_picker.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/pages/support_page.dart'; +import 'package:papyrus/services/support_api_client.dart'; +import 'package:papyrus/themes/app_theme.dart'; + +import '../helpers/support_file_picker.dart'; + +void main() { + setUp(() { + PackageInfo.setMockInitialValues( + appName: 'Papyrus', + packageName: 'papyrus', + version: '0.0.4', + buildNumber: '4', + buildSignature: '', + ); + }); + + testWidgets('validation, failure recovery, and single successful submission', (tester) async { + tester.view.physicalSize = const Size(1000, 1100); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final completion = Completer(); + var calls = 0; + final client = SupportApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.example')), + httpClient: MockClient((request) async { + calls++; + expect(request.body, contains('"app_version":"0.0.4 (4)"')); + + if (calls == 1) { + return http.Response('', 503); + } + + return completion.future; + }), + ); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.eink, + home: SupportPage(client: client), + ), + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Submit')); + await tester.tap(find.text('Submit')); + await tester.pumpAndSettle(); + expect(find.text('Enter a title.'), findsOneWidget); + expect(calls, 0); + await tester.enterText(find.byType(TextFormField).at(0), 'Reader issue'); + await tester.enterText(find.byType(TextFormField).at(1), 'The reader shows a blank page.'); + await tester.ensureVisible(find.text('Submit')); + await tester.tap(find.text('Submit')); + await tester.pumpAndSettle(); + expect(find.text('Submission failed.'), findsOneWidget); + expect(find.text('Check existing issues'), findsNothing); + expect(find.text('The reader shows a blank page.'), findsOneWidget); + await tester.ensureVisible(find.text('Submit')); + await tester.tap(find.text('Submit')); + await tester.pump(); + expect(find.text('Submitting…'), findsOneWidget); + await tester.tap(find.text('Submitting…')); + expect(calls, 2); + completion.complete(http.Response('{"number":42,"url":"https://github.com/PapyrusReader/client/issues/42"}', 201)); + await tester.pumpAndSettle(); + expect(find.text('Thank you for helping us improve.').hitTestable(), findsOneWidget); + expect(find.text('View issue'), findsOneWidget); + expect(find.text('Submit'), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets('compact form submits a feature request with large text', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + String? submittedKind; + final client = SupportApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.example')), + httpClient: MockClient((request) async { + submittedKind = request.body.contains('"kind":"feature"') ? 'feature' : 'bug'; + return http.Response('{"number":7,"url":"https://github.com/PapyrusReader/client/issues/7"}', 201); + }), + ); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: const TextScaler.linear(1.5)), + child: child!, + ), + home: SupportPage(client: client), + ), + ); + await tester.pumpAndSettle(); + expect(find.textContaining('GitHub'), findsNothing); + expect(find.textContaining('repository'), findsNothing); + expect(find.textContaining('Feedback and attachments are public.'), findsOneWidget); + await tester.tap(find.byType(DropdownButtonFormField)); + await tester.pumpAndSettle(); + await tester.tap(find.text('Feature request').last); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).first, 'More fonts'); + await tester.enterText(find.byType(TextFormField).last, 'Please add more font choices.'); + await tester.ensureVisible(find.text('Submit')); + await tester.tap(find.text('Submit')); + await tester.pumpAndSettle(); + expect(submittedKind, 'feature'); + expect(find.text('Thank you for helping us improve.').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + testWidgets('selected media can be removed and survives failed submission', (tester) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final picker = SupportFilePicker(); + FilePicker.platform = picker; + picker.result = FilePickerResult([ + PlatformFile(name: 'screen.png', size: 3, readStream: Stream.value([1, 2, 3])), + PlatformFile(name: 'demo.mp4', size: 2, readStream: Stream.value([4, 5])), + ]); + final client = SupportApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.example')), + httpClient: MockClient((request) async { + expect(request.body, contains('filename="demo.mp4"')); + expect(request.body, isNot(contains('filename="screen.png"'))); + return http.Response('', 502); + }), + ); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark, + home: SupportPage(client: client), + ), + ); + await tester.pumpAndSettle(); + await tester.ensureVisible(find.text('Attach images or videos')); + await tester.tap(find.text('Attach images or videos')); + await tester.pumpAndSettle(); + expect(find.text('screen.png'), findsOneWidget); + expect(find.text('demo.mp4'), findsOneWidget); + await tester.ensureVisible(find.byTooltip('Remove screen.png')); + await tester.tap(find.byTooltip('Remove screen.png')); + await tester.pumpAndSettle(); + expect(find.text('screen.png'), findsNothing); + picker.result = null; + await tester.ensureVisible(find.text('Attach images or videos')); + await tester.tap(find.text('Attach images or videos')); + await tester.pumpAndSettle(); + expect(find.text('demo.mp4'), findsOneWidget); + await tester.enterText(find.byType(TextFormField).first, 'Reader issue'); + await tester.enterText(find.byType(TextFormField).last, 'The reader shows a blank page.'); + await tester.ensureVisible(find.text('Submit')); + await tester.tap(find.text('Submit')); + await tester.pumpAndSettle(); + expect(find.text('Submission failed.'), findsOneWidget); + expect(find.text('demo.mp4'), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/app/test/services/support_api_client_test.dart b/app/test/services/support_api_client_test.dart new file mode 100644 index 00000000..c9a7c113 --- /dev/null +++ b/app/test/services/support_api_client_test.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:papyrus/services/support_attachment.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/services/support_api_client.dart'; + +void main() { + test('submission uses configured server and no account credentials', () async { + final client = SupportApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://custom.example'), apiPrefix: '/api'), + httpClient: MockClient((request) async { + expect(request.url.toString(), 'https://custom.example/api/support/issues'); + expect(request.headers.containsKey('authorization'), isFalse); + expect(request.headers['content-type'], startsWith('multipart/form-data; boundary=')); + expect( + request.body, + contains( + jsonEncode({ + 'kind': 'feature', + 'title': 'More fonts', + 'description': 'Please add more font choices.', + 'app_version': '0.0.4 (4)', + 'platform': 'android', + }), + ), + ); + expect(request.body, contains('filename="screen.png"')); + expect(request.body, contains('content-type: image/png')); + expect(request.body, contains('image bytes')); + expect(request.body, contains('filename="demo.mp4"')); + expect(request.body, contains('content-type: video/mp4')); + expect(request.body, contains('video bytes')); + return http.Response('{"number":42,"url":"https://github.com/PapyrusReader/client/issues/42"}', 201); + }), + ); + addTearDown(client.close); + final issue = await client.submit( + kind: 'feature', + title: ' More fonts ', + description: 'Please add more font choices.', + appVersion: '0.0.4 (4)', + platform: 'android', + attachments: [ + SupportAttachment( + name: 'screen.png', + bytes: Uint8List.fromList(utf8.encode('image bytes')), + contentType: 'image/png', + ), + SupportAttachment( + name: 'demo.mp4', + bytes: Uint8List.fromList(utf8.encode('video bytes')), + contentType: 'video/mp4', + ), + ], + ); + expect(issue.number, 42); + }); + + for (final status in [404, 422, 429, 502, 503, 504]) { + test('status $status is a failure with no automatic retry', () async { + var calls = 0; + final client = SupportApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.example')), + httpClient: MockClient((request) async { + calls++; + return http.Response('private upstream details', status); + }), + ); + addTearDown(client.close); + await expectLater( + client.submit(kind: 'bug', title: 'Title', description: 'Description', appVersion: '1', platform: 'web'), + throwsA(isA()), + ); + expect(calls, 1); + }); + } + + test('accepts the configured server repository issue link', () async { + final client = SupportApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.example')), + httpClient: MockClient((request) async { + return http.Response('{"number":42,"url":"https://github.com/ExampleOrg/reader-support/issues/42"}', 201); + }), + ); + addTearDown(client.close); + final issue = await client.submit( + kind: 'bug', + title: 'Title', + description: 'Description', + appVersion: '1', + platform: 'web', + ); + expect(issue.url.toString(), 'https://github.com/ExampleOrg/reader-support/issues/42'); + }); + + for (final url in [ + 'https://evil.example/Org/repo/issues/42', + 'http://github.com/Org/repo/issues/42', + 'https://github.com@evil.example/Org/repo/issues/42', + 'https://github.com/Org/repo/issues/43', + 'https://github.com/Org/repo/pull/42', + 'https://github.com/Org/repo/issues/42?redirect=evil', + 'https://github.com/Org/repo/issues/42#fragment', + ]) { + test('rejects unrelated or malformed support link: $url', () async { + final client = SupportApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.example')), + httpClient: MockClient((request) async => http.Response(jsonEncode({'number': 42, 'url': url}), 201)), + ); + addTearDown(client.close); + await expectLater( + client.submit(kind: 'bug', title: 'Title', description: 'Description', appVersion: '1', platform: 'web'), + throwsA(isA()), + ); + }); + } +} diff --git a/app/test/services/support_attachment_test.dart b/app/test/services/support_attachment_test.dart new file mode 100644 index 00000000..cd92c626 --- /dev/null +++ b/app/test/services/support_attachment_test.dart @@ -0,0 +1,51 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/services/support_attachment.dart'; + +import '../helpers/support_file_picker.dart'; + +void main() { + late SupportFilePicker picker; + + setUp(() { + picker = SupportFilePicker(); + FilePicker.platform = picker; + }); + + test('cancelling selection leaves attachments unchanged', () async { + expect(await SupportAttachment.pick(5), isEmpty); + }); + + test('reads images and videos from streams for retryable submissions', () async { + picker.result = FilePickerResult([ + PlatformFile(name: 'screenshot.PNG', size: 3, readStream: Stream.value([1, 2, 3])), + PlatformFile(name: 'recording.mp4', size: 2, readStream: Stream.value([4, 5])), + ]); + final attachments = await SupportAttachment.pick(5); + expect(attachments.first.contentType, 'image/png'); + expect(attachments.first.bytes, [1, 2, 3]); + expect(attachments.last.isVideo, isTrue); + expect(attachments.last.bytes, [4, 5]); + }); + + test('rejects excess count and oversized files before reading bytes', () async { + picker.result = FilePickerResult([PlatformFile(name: 'large.png', size: SupportAttachment.maxBytes + 1)]); + await expectLater(SupportAttachment.pick(0), throwsFormatException); + await expectLater(SupportAttachment.pick(5), throwsFormatException); + }); + + test('rejects unsupported types, empty files, and a stream larger than declared', () async { + for (final file in [ + PlatformFile(name: 'document.pdf', size: 1), + PlatformFile(name: 'empty.png', size: 0), + PlatformFile( + name: 'large.png', + size: 1, + readStream: Stream.value(List.filled(SupportAttachment.maxBytes + 1, 0)), + ), + ]) { + picker.result = FilePickerResult([file]); + await expectLater(SupportAttachment.pick(5), throwsFormatException); + } + }); +} diff --git a/app/test/widgets/profile/about_settings_section_test.dart b/app/test/widgets/profile/about_settings_section_test.dart new file mode 100644 index 00000000..399c7502 --- /dev/null +++ b/app/test/widgets/profile/about_settings_section_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:papyrus/widgets/profile/about_settings_section.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final launched = []; + + setUp(() { + PackageInfo.setMockInitialValues( + appName: 'Papyrus', + packageName: 'papyrus', + version: '2.3.4', + buildNumber: '57', + buildSignature: '', + ); + launched.clear(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + (call) async { + if (call.method == 'launch') { + launched.add((call.arguments as Map)['url'] as String); + return true; + } + + return null; + }, + ); + }); + + for (final desktop in [false, true]) { + testWidgets('About uses installed version and working destinations, desktop=$desktop', (tester) async { + final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => Scaffold(body: AboutSettingsSection(isDesktop: desktop)), + ), + GoRoute( + path: '/profile/licenses', + builder: (context, state) => const Scaffold(body: Text('License destination')), + ), + GoRoute( + path: '/profile/support', + builder: (context, state) => const Scaffold(body: Text('Support destination')), + ), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget(MaterialApp.router(routerConfig: router)); + await tester.pumpAndSettle(); + expect(find.text('2.3.4 (57)'), findsOneWidget); + expect(find.text('1.0.0'), findsNothing); + await tester.tap(find.text("What's new")); + await tester.pumpAndSettle(); + await tester.tap(find.text('GitHub repository')); + await tester.pumpAndSettle(); + expect(launched, [ + 'https://github.com/PapyrusReader/client/releases/tag/v2.3.4+57', + 'https://github.com/PapyrusReader/client', + ]); + await tester.tap(find.text('Licenses')); + await tester.pumpAndSettle(); + expect(find.text('License destination'), findsOneWidget); + router.pop(); + await tester.pumpAndSettle(); + await tester.tap(find.text('Support')); + await tester.pumpAndSettle(); + expect(find.text('Support destination'), findsOneWidget); + }); + } + + testWidgets('failed external link provides feedback', (tester) async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/url_launcher'), + (call) async => false, + ); + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: AboutSettingsSection()))); + await tester.pumpAndSettle(); + await tester.tap(find.text('GitHub repository')); + await tester.pumpAndSettle(); + expect(find.textContaining('Could not open'), findsOneWidget); + }); +} diff --git a/app/test/widgets/profile/support_confirmation_test.dart b/app/test/widgets/profile/support_confirmation_test.dart new file mode 100644 index 00000000..a20d994c --- /dev/null +++ b/app/test/widgets/profile/support_confirmation_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:papyrus/services/support_api_client.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/profile/support_confirmation.dart'; + +void main() { + final issue = SupportIssue(number: 74, url: Uri.parse('https://github.com/PapyrusReader/client/issues/74')); + + for (final theme in {'dark': AppTheme.dark, 'light': AppTheme.light, 'eink': AppTheme.eink}.entries) { + for (final compact in [false, true]) { + testWidgets('confirmation link is reachable in ${theme.key}, compact=$compact', (tester) async { + tester.view.physicalSize = Size(compact ? 320 : 900, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + String? launched; + const channel = MethodChannel('plugins.flutter.io/url_launcher'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'launch') { + launched = (call.arguments as Map)['url'] as String; + return true; + } + + return null; + }); + addTearDown(() => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, null)); + await tester.pumpWidget( + MaterialApp( + theme: theme.value, + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(textScaler: TextScaler.linear(compact ? 1.5 : 1)), + child: child!, + ), + home: Scaffold( + body: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: SupportConfirmation(issue: issue), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('#74'), findsOneWidget); + expect(find.byIcon(Icons.open_in_new), findsOneWidget); + expect(tester.takeException(), isNull); + await tester.ensureVisible(find.text('View issue')); + await tester.tap(find.text('View issue')); + await tester.pumpAndSettle(); + expect(launched, issue.url.toString()); + expect(tester.takeException(), isNull); + }); + } + } +} diff --git a/docs/about-and-support.md b/docs/about-and-support.md new file mode 100644 index 00000000..ce13d922 --- /dev/null +++ b/docs/about-and-support.md @@ -0,0 +1,16 @@ +# About and support + +About reads the installed package version and build number. What's new opens the +matching `v+` release in `PapyrusReader/client`, using the release notes +generated by the release workflow. Development versions without a published tag +do not have release notes yet. Licenses are bundled with the app and work offline. + +Support submits a bug report or feature request to the selected Papyrus server. +No GitHub account is required. Its text, attachments, and app +version/platform become a public client-repository issue. The server must enable +`SUPPORT_GITHUB_TOKEN`, as documented in the [server support guide](https://github.com/PapyrusReader/server/blob/development/docs/support.md). Failed submissions +keep the current form text and selected files, and uncertain submissions are not retried automatically. + +Attach up to five images or videos, 10 MB per file. PNG, JPEG, GIF, WebP, MP4, MOV, +and WebM are supported. Files can be removed before submission and upload only +when Submit is pressed. Images and videos are embedded in the GitHub issue. From 81a2b933431f1bb0e8f2b856375403bdc68dffba Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Fri, 23 Oct 2026 21:38:14 +0300 Subject: [PATCH 2/4] fix: guard support route during submission --- app/lib/config/app_router.dart | 8 +- app/lib/pages/support_page.dart | 15 ++ app/test/pages/support_navigation_test.dart | 164 ++++++++++++++++++++ 3 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 app/test/pages/support_navigation_test.dart diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index b53377e4..bc750885 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -275,12 +275,8 @@ class AppRouter { path: 'licenses', pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const LicensesPage()), ), - GoRoute( - path: 'support', - pageBuilder: (context, state) => NoTransitionPage( - key: state.pageKey, - child: SupportPage(client: SupportApiClient(config: syncSettingsProvider.activeApiConfig)), - ), + supportRoute( + createClient: () => SupportApiClient(config: syncSettingsProvider.activeApiConfig), ), GoRoute( name: 'EDIT_PROFILE', diff --git a/app/lib/pages/support_page.dart b/app/lib/pages/support_page.dart index fef3d450..506e2f4c 100644 --- a/app/lib/pages/support_page.dart +++ b/app/lib/pages/support_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:papyrus/services/about_links.dart'; import 'package:papyrus/services/app_info.dart'; import 'package:papyrus/services/support_api_client.dart'; @@ -8,6 +9,20 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/library/library_page_header.dart'; import 'package:papyrus/widgets/profile/support_confirmation.dart'; +GoRoute supportRoute({required SupportApiClient Function() createClient}) { + final pageKey = GlobalKey<_SupportPageState>(); + + return GoRoute( + path: 'support', + // PopScope only covers Back. Shell navigation replaces routes with go(). + onExit: (_, _) => pageKey.currentState?._sending != true, + pageBuilder: (_, state) => NoTransitionPage( + key: state.pageKey, + child: SupportPage(key: pageKey, client: createClient()), + ), + ); +} + class SupportPage extends StatefulWidget { const SupportPage({super.key, required this.client}); diff --git a/app/test/pages/support_navigation_test.dart b/app/test/pages/support_navigation_test.dart new file mode 100644 index 00000000..cb13d0a5 --- /dev/null +++ b/app/test/pages/support_navigation_test.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:papyrus/auth/papyrus_api_config.dart'; +import 'package:papyrus/data/data_store.dart'; +import 'package:papyrus/opds/opds_catalog_store.dart'; +import 'package:papyrus/opds/opds_catalogs.dart'; +import 'package:papyrus/pages/support_page.dart'; +import 'package:papyrus/providers/sidebar_provider.dart'; +import 'package:papyrus/services/support_api_client.dart'; +import 'package:papyrus/themes/app_theme.dart'; +import 'package:papyrus/widgets/shell/adaptive_app_shell.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../opds/catalog_store_test.dart' show MemorySecrets; + +class _TrackingClient extends MockClient { + _TrackingClient(super.handler); + + bool closed = false; + + @override + void close() { + closed = true; + super.close(); + } +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + + PackageInfo.setMockInitialValues( + appName: 'Papyrus', + packageName: 'papyrus', + version: '0.0.4', + buildNumber: '4', + buildSignature: '', + ); + }); + + for (final width in [390.0, 1280.0]) { + for (final succeeds in [true, false]) { + testWidgets('submission guards shell navigation at $width and releases after success=$succeeds', (tester) async { + tester.view.physicalSize = Size(width, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final completion = Completer(); + final clients = <_TrackingClient>[]; + var submissions = 0; + + final catalogs = OpdsCatalogs(OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets())) + ..setScope('guest'); + + addTearDown(catalogs.dispose); + + final router = GoRouter( + initialLocation: '/profile/support', + routes: [ + ShellRoute( + builder: (_, _, child) => AdaptiveAppShell(child: child), + routes: [ + GoRoute(path: '/dashboard', builder: (_, _) => const Text('Dashboard page')), + GoRoute( + path: '/profile', + builder: (_, _) => const Text('Profile page'), + routes: [ + supportRoute( + createClient: () { + final client = _TrackingClient((_) { + submissions++; + return completion.future; + }); + + clients.add(client); + + return SupportApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.example')), + httpClient: client, + ); + }, + ), + ], + ), + ], + ), + ], + ); + + addTearDown(router.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => DataStore()), + ChangeNotifierProvider(create: (_) => SidebarProvider()), + ChangeNotifierProvider.value(value: catalogs), + ], + child: MaterialApp.router(theme: AppTheme.eink, routerConfig: router), + ), + ); + + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).first, 'Reader issue'); + await tester.enterText(find.byType(TextFormField).last, 'The reader shows a blank page.'); + await tester.ensureVisible(find.text('Submit')); + await tester.tap(find.text('Submit')); + await tester.pumpAndSettle(); + final submittingPage = tester.state(find.byType(SupportPage)); + final submittingClient = clients.last; + + for (final destination in ['Dashboard', 'Profile']) { + await tester.tap(find.text(destination)); + await tester.pumpAndSettle(); + expect(router.routerDelegate.currentConfiguration.uri.path, '/profile/support'); + expect(tester.state(find.byType(SupportPage)), same(submittingPage)); + expect(submittingClient.closed, isFalse); + } + + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(router.routerDelegate.currentConfiguration.uri.path, '/profile/support'); + expect(submittingClient.closed, isFalse); + expect(submissions, 1); + + completion.complete( + succeeds + ? http.Response('{"number":42,"url":"https://github.com/PapyrusReader/client/issues/42"}', 201) + : http.Response('', 503), + ); + + await tester.pumpAndSettle(); + + if (succeeds) { + expect(find.text('Thank you for helping us improve.'), findsOneWidget); + } else { + expect(find.text('Submission failed.'), findsOneWidget); + expect(find.text('The reader shows a blank page.'), findsOneWidget); + } + + await tester.tap(find.text('Dashboard')); + await tester.pumpAndSettle(); + expect(find.text('Dashboard page'), findsOneWidget); + expect(find.byType(SupportPage), findsNothing); + expect(submittingClient.closed, isTrue); + router.go('/profile/support'); + await tester.pumpAndSettle(); + expect(find.byType(SupportPage), findsOneWidget); + expect(find.text('Submitting…'), findsNothing); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Profile page'), findsOneWidget); + expect(clients.last.closed, isTrue); + expect(tester.takeException(), isNull); + }); + } + } +} From 992c37cbb5b9dd73fba45fc3843bd5832394b6c2 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Fri, 23 Oct 2026 21:46:46 +0300 Subject: [PATCH 3/4] fix: let support submissions finish after navigation --- app/lib/config/app_router.dart | 8 +- app/lib/pages/support_page.dart | 278 ++++++++++---------- app/test/pages/support_navigation_test.dart | 215 +++++++-------- 3 files changed, 248 insertions(+), 253 deletions(-) diff --git a/app/lib/config/app_router.dart b/app/lib/config/app_router.dart index bc750885..b53377e4 100644 --- a/app/lib/config/app_router.dart +++ b/app/lib/config/app_router.dart @@ -275,8 +275,12 @@ class AppRouter { path: 'licenses', pageBuilder: (context, state) => NoTransitionPage(key: state.pageKey, child: const LicensesPage()), ), - supportRoute( - createClient: () => SupportApiClient(config: syncSettingsProvider.activeApiConfig), + GoRoute( + path: 'support', + pageBuilder: (context, state) => NoTransitionPage( + key: state.pageKey, + child: SupportPage(client: SupportApiClient(config: syncSettingsProvider.activeApiConfig)), + ), ), GoRoute( name: 'EDIT_PROFILE', diff --git a/app/lib/pages/support_page.dart b/app/lib/pages/support_page.dart index 506e2f4c..472a2a91 100644 --- a/app/lib/pages/support_page.dart +++ b/app/lib/pages/support_page.dart @@ -1,6 +1,5 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:papyrus/services/about_links.dart'; import 'package:papyrus/services/app_info.dart'; import 'package:papyrus/services/support_api_client.dart'; @@ -9,20 +8,6 @@ import 'package:papyrus/themes/design_tokens.dart'; import 'package:papyrus/widgets/library/library_page_header.dart'; import 'package:papyrus/widgets/profile/support_confirmation.dart'; -GoRoute supportRoute({required SupportApiClient Function() createClient}) { - final pageKey = GlobalKey<_SupportPageState>(); - - return GoRoute( - path: 'support', - // PopScope only covers Back. Shell navigation replaces routes with go(). - onExit: (_, _) => pageKey.currentState?._sending != true, - pageBuilder: (_, state) => NoTransitionPage( - key: state.pageKey, - child: SupportPage(key: pageKey, client: createClient()), - ), - ); -} - class SupportPage extends StatefulWidget { const SupportPage({super.key, required this.client}); @@ -71,7 +56,12 @@ class _SupportPageState extends State { _scrollController.dispose(); _title.dispose(); _description.dispose(); - widget.client.close(); + + // An active submission owns the client until it finishes, even after leaving. + if (!_sending) { + widget.client.close(); + } + super.dispose(); } @@ -114,8 +104,10 @@ class _SupportPageState extends State { _error = null; }); + final client = widget.client; + try { - final issue = await widget.client.submit( + final issue = await client.submit( kind: _kind, title: _title.text, description: _description.text, @@ -143,6 +135,8 @@ class _SupportPageState extends State { } finally { if (mounted) { setState(() => _sending = false); + } else { + client.close(); } } } @@ -153,144 +147,140 @@ class _SupportPageState extends State { final textTheme = Theme.of(context).textTheme; final compact = MediaQuery.sizeOf(context).width < Breakpoints.desktopSmall; - return PopScope( - canPop: !_sending, - child: Scaffold( - body: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - LibraryPageHeader(title: 'Support', leading: const BackButton(), compact: compact), - if (compact) const Divider(height: 1), - Expanded( - child: SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.all(Spacing.lg), - child: Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 640), - child: issue != null - ? SupportConfirmation(issue: issue) - : Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('How can we help?', style: textTheme.titleLarge), - const SizedBox(height: Spacing.md), - const Text( - 'Report a problem or suggest an improvement. Feedback and attachments are public. ' - 'Do not include passwords, private book content, or other personal information.', - ), - const SizedBox(height: Spacing.lg), - DropdownButtonFormField( - initialValue: _kind, - isExpanded: true, - decoration: const InputDecoration(labelText: 'Request type'), - items: const [ - DropdownMenuItem(value: 'bug', child: Text('Bug report')), - DropdownMenuItem(value: 'feature', child: Text('Feature request')), - ], - onChanged: _sending ? null : (value) => setState(() => _kind = value!), - ), - const SizedBox(height: Spacing.md), - TextFormField( - controller: _title, - enabled: !_sending, - maxLength: 150, - decoration: const InputDecoration(labelText: 'Title'), - validator: (value) => - value == null || value.trim().isEmpty ? 'Enter a title.' : null, - ), - const SizedBox(height: Spacing.md), - TextFormField( - controller: _description, - enabled: !_sending, - minLines: 6, - maxLines: 12, - maxLength: 8000, - decoration: InputDecoration( - labelText: 'Description', - alignLabelWithHint: true, - hintText: _kind == 'bug' - ? 'What happened? What did you expect? How can we reproduce it?' - : 'What would you like to do, and how would it help?', - ), - validator: (value) => value == null || value.trim().length < 10 - ? 'Please add a little more detail (at least 10 characters).' - : null, - ), - const SizedBox(height: Spacing.md), - OutlinedButton.icon( - onPressed: _sending || _picking || _attachments.length >= SupportAttachment.maxCount - ? null - : _pickAttachments, - icon: const Icon(Icons.attach_file), - label: Text(_picking ? 'Attaching…' : 'Attach images or videos'), + return Scaffold( + body: SafeArea( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + LibraryPageHeader(title: 'Support', leading: const BackButton(), compact: compact), + if (compact) const Divider(height: 1), + Expanded( + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.all(Spacing.lg), + child: Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 640), + child: issue != null + ? SupportConfirmation(issue: issue) + : Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('How can we help?', style: textTheme.titleLarge), + const SizedBox(height: Spacing.md), + const Text( + 'Report a problem or suggest an improvement. Feedback and attachments are public. ' + 'Do not include passwords, private book content, or other personal information.', + ), + const SizedBox(height: Spacing.lg), + DropdownButtonFormField( + initialValue: _kind, + isExpanded: true, + decoration: const InputDecoration(labelText: 'Request type'), + items: const [ + DropdownMenuItem(value: 'bug', child: Text('Bug report')), + DropdownMenuItem(value: 'feature', child: Text('Feature request')), + ], + onChanged: _sending ? null : (value) => setState(() => _kind = value!), + ), + const SizedBox(height: Spacing.md), + TextFormField( + controller: _title, + enabled: !_sending, + maxLength: 150, + decoration: const InputDecoration(labelText: 'Title'), + validator: (value) => value == null || value.trim().isEmpty ? 'Enter a title.' : null, + ), + const SizedBox(height: Spacing.md), + TextFormField( + controller: _description, + enabled: !_sending, + minLines: 6, + maxLines: 12, + maxLength: 8000, + decoration: InputDecoration( + labelText: 'Description', + alignLabelWithHint: true, + hintText: _kind == 'bug' + ? 'What happened? What did you expect? How can we reproduce it?' + : 'What would you like to do, and how would it help?', ), - const SizedBox(height: Spacing.xs), - Text('Up to 5 files · 10 MB each', style: textTheme.bodySmall), - for (final attachment in _attachments) - Padding( - padding: const EdgeInsets.only(top: Spacing.sm), - child: Row( - children: [ - Icon(attachment.isVideo ? Icons.videocam_outlined : Icons.image_outlined), - const SizedBox(width: Spacing.sm), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(attachment.name, maxLines: 2, overflow: TextOverflow.ellipsis), - Text(attachment.sizeLabel, style: textTheme.bodySmall), - ], - ), + validator: (value) => value == null || value.trim().length < 10 + ? 'Please add a little more detail (at least 10 characters).' + : null, + ), + const SizedBox(height: Spacing.md), + OutlinedButton.icon( + onPressed: _sending || _picking || _attachments.length >= SupportAttachment.maxCount + ? null + : _pickAttachments, + icon: const Icon(Icons.attach_file), + label: Text(_picking ? 'Attaching…' : 'Attach images or videos'), + ), + const SizedBox(height: Spacing.xs), + Text('Up to 5 files · 10 MB each', style: textTheme.bodySmall), + for (final attachment in _attachments) + Padding( + padding: const EdgeInsets.only(top: Spacing.sm), + child: Row( + children: [ + Icon(attachment.isVideo ? Icons.videocam_outlined : Icons.image_outlined), + const SizedBox(width: Spacing.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(attachment.name, maxLines: 2, overflow: TextOverflow.ellipsis), + Text(attachment.sizeLabel, style: textTheme.bodySmall), + ], ), - IconButton( - tooltip: 'Remove ${attachment.name}', - onPressed: _sending || _picking - ? null - : () => setState(() => _attachments.remove(attachment)), - icon: const Icon(Icons.close), - ), - ], - ), + ), + IconButton( + tooltip: 'Remove ${attachment.name}', + onPressed: _sending || _picking + ? null + : () => setState(() => _attachments.remove(attachment)), + icon: const Icon(Icons.close), + ), + ], ), - if (_attachmentError != null) ...[ - const SizedBox(height: Spacing.sm), - Semantics( - liveRegion: true, - child: Text( - _attachmentError!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), + ), + if (_attachmentError != null) ...[ + const SizedBox(height: Spacing.sm), + Semantics( + liveRegion: true, + child: Text( + _attachmentError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), ), - ], - if (_error != null) ...[ - const SizedBox(height: Spacing.sm), - Semantics( - liveRegion: true, - child: Text( - _error!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), + ), + ], + if (_error != null) ...[ + const SizedBox(height: Spacing.sm), + Semantics( + liveRegion: true, + child: Text( + _error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), ), - ], - const SizedBox(height: Spacing.lg), - FilledButton( - onPressed: _sending || _picking ? null : _submit, - child: Text(_sending ? 'Submitting…' : 'Submit'), ), ], - ), + const SizedBox(height: Spacing.lg), + FilledButton( + onPressed: _sending || _picking ? null : _submit, + child: Text(_sending ? 'Submitting…' : 'Submit'), + ), + ], ), - ), + ), ), ), ), - ], - ), + ), + ], ), ), ); diff --git a/app/test/pages/support_navigation_test.dart b/app/test/pages/support_navigation_test.dart index cb13d0a5..de9b1294 100644 --- a/app/test/pages/support_navigation_test.dart +++ b/app/test/pages/support_navigation_test.dart @@ -46,119 +46,120 @@ void main() { }); for (final width in [390.0, 1280.0]) { - for (final succeeds in [true, false]) { - testWidgets('submission guards shell navigation at $width and releases after success=$succeeds', (tester) async { - tester.view.physicalSize = Size(width, 1000); - tester.view.devicePixelRatio = 1; - addTearDown(tester.view.reset); - final completion = Completer(); - final clients = <_TrackingClient>[]; - var submissions = 0; - - final catalogs = OpdsCatalogs(OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets())) - ..setScope('guest'); - - addTearDown(catalogs.dispose); - - final router = GoRouter( - initialLocation: '/profile/support', - routes: [ - ShellRoute( - builder: (_, _, child) => AdaptiveAppShell(child: child), - routes: [ - GoRoute(path: '/dashboard', builder: (_, _) => const Text('Dashboard page')), - GoRoute( - path: '/profile', - builder: (_, _) => const Text('Profile page'), - routes: [ - supportRoute( - createClient: () { - final client = _TrackingClient((_) { - submissions++; - return completion.future; - }); - - clients.add(client); - - return SupportApiClient( - config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.example')), - httpClient: client, - ); - }, - ), - ], - ), + for (final destination in ['Dashboard', 'Back']) { + for (final succeeds in [true, false]) { + testWidgets('$destination leaves during submission at $width, then success=$succeeds closes the client', ( + tester, + ) async { + tester.view.physicalSize = Size(width, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final completion = Completer(); + final clients = <_TrackingClient>[]; + var submissions = 0; + + final catalogs = OpdsCatalogs( + OpdsCatalogStore(await SharedPreferences.getInstance(), secrets: MemorySecrets()), + )..setScope('guest'); + + addTearDown(catalogs.dispose); + + final router = GoRouter( + initialLocation: '/profile/support', + routes: [ + ShellRoute( + builder: (_, _, child) => AdaptiveAppShell(child: child), + routes: [ + GoRoute(path: '/dashboard', builder: (_, _) => const Text('Dashboard page')), + GoRoute( + path: '/profile', + builder: (_, _) => const Text('Profile page'), + routes: [ + GoRoute( + path: 'support', + pageBuilder: (_, state) { + final client = _TrackingClient((_) { + submissions++; + return completion.future; + }); + + clients.add(client); + + return NoTransitionPage( + key: state.pageKey, + child: SupportPage( + client: SupportApiClient( + config: PapyrusApiConfig(serverBaseUri: Uri.parse('https://api.example')), + httpClient: client, + ), + ), + ); + }, + ), + ], + ), + ], + ), + ], + ); + + addTearDown(router.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => DataStore()), + ChangeNotifierProvider(create: (_) => SidebarProvider()), + ChangeNotifierProvider.value(value: catalogs), ], + child: MaterialApp.router(theme: AppTheme.eink, routerConfig: router), ), - ], - ); + ); - addTearDown(router.dispose); + await tester.pumpAndSettle(); + await tester.enterText(find.byType(TextFormField).first, 'Reader issue'); + await tester.enterText(find.byType(TextFormField).last, 'The reader shows a blank page.'); + await tester.ensureVisible(find.text('Submit')); + await tester.tap(find.text('Submit')); + await tester.pumpAndSettle(); + final submittingClient = clients.last; + + if (destination == 'Back') { + await tester.tap(find.byType(BackButton)); + } else { + await tester.tap(find.text(destination)); + } - await tester.pumpWidget( - MultiProvider( - providers: [ - ChangeNotifierProvider(create: (_) => DataStore()), - ChangeNotifierProvider(create: (_) => SidebarProvider()), - ChangeNotifierProvider.value(value: catalogs), - ], - child: MaterialApp.router(theme: AppTheme.eink, routerConfig: router), - ), - ); - - await tester.pumpAndSettle(); - await tester.enterText(find.byType(TextFormField).first, 'Reader issue'); - await tester.enterText(find.byType(TextFormField).last, 'The reader shows a blank page.'); - await tester.ensureVisible(find.text('Submit')); - await tester.tap(find.text('Submit')); - await tester.pumpAndSettle(); - final submittingPage = tester.state(find.byType(SupportPage)); - final submittingClient = clients.last; - - for (final destination in ['Dashboard', 'Profile']) { - await tester.tap(find.text(destination)); await tester.pumpAndSettle(); - expect(router.routerDelegate.currentConfiguration.uri.path, '/profile/support'); - expect(tester.state(find.byType(SupportPage)), same(submittingPage)); + expect(find.text(destination == 'Back' ? 'Profile page' : 'Dashboard page'), findsOneWidget); + expect(find.byType(SupportPage), findsNothing); expect(submittingClient.closed, isFalse); - } - - await tester.tap(find.byType(BackButton)); - await tester.pumpAndSettle(); - expect(router.routerDelegate.currentConfiguration.uri.path, '/profile/support'); - expect(submittingClient.closed, isFalse); - expect(submissions, 1); - - completion.complete( - succeeds - ? http.Response('{"number":42,"url":"https://github.com/PapyrusReader/client/issues/42"}', 201) - : http.Response('', 503), - ); - - await tester.pumpAndSettle(); - - if (succeeds) { - expect(find.text('Thank you for helping us improve.'), findsOneWidget); - } else { - expect(find.text('Submission failed.'), findsOneWidget); - expect(find.text('The reader shows a blank page.'), findsOneWidget); - } - - await tester.tap(find.text('Dashboard')); - await tester.pumpAndSettle(); - expect(find.text('Dashboard page'), findsOneWidget); - expect(find.byType(SupportPage), findsNothing); - expect(submittingClient.closed, isTrue); - router.go('/profile/support'); - await tester.pumpAndSettle(); - expect(find.byType(SupportPage), findsOneWidget); - expect(find.text('Submitting…'), findsNothing); - await tester.tap(find.byType(BackButton)); - await tester.pumpAndSettle(); - expect(find.text('Profile page'), findsOneWidget); - expect(clients.last.closed, isTrue); - expect(tester.takeException(), isNull); - }); + expect(submissions, 1); + router.go('/profile/support'); + await tester.pumpAndSettle(); + expect(find.byType(SupportPage), findsOneWidget); + expect(find.text('Submitting…'), findsNothing); + expect(find.text('Reader issue'), findsNothing); + expect(submittingClient.closed, isFalse); + await tester.tap(find.byType(BackButton)); + await tester.pumpAndSettle(); + expect(find.text('Profile page'), findsOneWidget); + expect(clients.last.closed, isTrue); + + completion.complete( + succeeds + ? http.Response('{"number":42,"url":"https://github.com/PapyrusReader/client/issues/42"}', 201) + : http.Response('', 503), + ); + + await tester.pumpAndSettle(); + + expect(find.text('Profile page'), findsOneWidget); + expect(submittingClient.closed, isTrue); + expect(submissions, 1); + expect(tester.takeException(), isNull); + }); + } } } } From f02fad62667ea2216014da701e72ecf6ff378fd2 Mon Sep 17 00:00:00 2001 From: Karolis Strazdas Date: Fri, 23 Oct 2026 21:49:18 +0300 Subject: [PATCH 4/4] fix: simplify support form introduction --- app/lib/pages/support_page.dart | 2 +- app/test/pages/support_page_test.dart | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/lib/pages/support_page.dart b/app/lib/pages/support_page.dart index 472a2a91..e00e410d 100644 --- a/app/lib/pages/support_page.dart +++ b/app/lib/pages/support_page.dart @@ -172,7 +172,7 @@ class _SupportPageState extends State { Text('How can we help?', style: textTheme.titleLarge), const SizedBox(height: Spacing.md), const Text( - 'Report a problem or suggest an improvement. Feedback and attachments are public. ' + 'Report a problem or suggest an improvement. ' 'Do not include passwords, private book content, or other personal information.', ), const SizedBox(height: Spacing.lg), diff --git a/app/test/pages/support_page_test.dart b/app/test/pages/support_page_test.dart index 9873ed86..4d1d8146 100644 --- a/app/test/pages/support_page_test.dart +++ b/app/test/pages/support_page_test.dart @@ -104,7 +104,6 @@ void main() { await tester.pumpAndSettle(); expect(find.textContaining('GitHub'), findsNothing); expect(find.textContaining('repository'), findsNothing); - expect(find.textContaining('Feedback and attachments are public.'), findsOneWidget); await tester.tap(find.byType(DropdownButtonFormField)); await tester.pumpAndSettle(); await tester.tap(find.text('Feature request').last);