636 lines
22 KiB
Dart
636 lines
22 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_markdown_plus/flutter_markdown_plus.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:package_info_plus/package_info_plus.dart';
|
|
import 'package:sharedinbox/core/models/account.dart';
|
|
import 'package:sharedinbox/core/models/email.dart';
|
|
import 'package:sharedinbox/core/repositories/sync_log_repository.dart';
|
|
import 'package:sharedinbox/di.dart';
|
|
import 'package:sharedinbox/ui/utils/about_markdown.dart';
|
|
|
|
const _bugReportApiUrl = String.fromEnvironment(
|
|
'BUG_REPORT_API_URL',
|
|
defaultValue: 'https://sharedinbox.de/api/v1/bug-reports',
|
|
);
|
|
|
|
class BugReportScreen extends ConsumerStatefulWidget {
|
|
const BugReportScreen({super.key, this.emailId});
|
|
|
|
final String? emailId;
|
|
|
|
@override
|
|
ConsumerState<BugReportScreen> createState() => _BugReportScreenState();
|
|
}
|
|
|
|
class _BugReportScreenState extends ConsumerState<BugReportScreen> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _descriptionController = TextEditingController();
|
|
final _emailController = TextEditingController();
|
|
|
|
final Future<PackageInfo> _packageInfoFuture = PackageInfo.fromPlatform();
|
|
late final Future<String?> _deviceModelFuture = getDeviceModel();
|
|
|
|
final List<PlatformFile> _attachments = [];
|
|
bool _includeEmail = false;
|
|
bool _includeSyncLog = false;
|
|
bool _submitting = false;
|
|
|
|
Email? _attachedEmail;
|
|
List<Account> _accounts = [];
|
|
String? _selectedAccountId;
|
|
String? _deviceModel;
|
|
bool _loadingEmail = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
unawaited(_loadInitialData());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_descriptionController.dispose();
|
|
_emailController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _loadInitialData() async {
|
|
setState(() => _loadingEmail = true);
|
|
try {
|
|
_deviceModel = await _deviceModelFuture;
|
|
_accounts =
|
|
await ref.read(accountRepositoryProvider).observeAccounts().first;
|
|
|
|
if (widget.emailId != null) {
|
|
final email =
|
|
await ref.read(emailRepositoryProvider).getEmail(widget.emailId!);
|
|
if (mounted && email != null) {
|
|
_attachedEmail = email;
|
|
_selectedAccountId = email.accountId;
|
|
final fromStr =
|
|
email.from.isNotEmpty ? email.from.first.toString() : 'unknown';
|
|
final subjectStr = email.subject ?? '(no subject)';
|
|
_descriptionController.text =
|
|
'Problem with email from $fromStr: "$subjectStr"\n\n';
|
|
}
|
|
}
|
|
|
|
if (_selectedAccountId == null && _accounts.isNotEmpty) {
|
|
_selectedAccountId = _accounts.first.id;
|
|
}
|
|
|
|
if (_selectedAccountId != null) {
|
|
final matching =
|
|
_accounts.where((a) => a.id == _selectedAccountId).firstOrNull;
|
|
if (matching != null) {
|
|
_emailController.text = matching.email;
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
if (mounted) {
|
|
setState(() => _loadingEmail = false);
|
|
}
|
|
}
|
|
|
|
int get _totalAttachmentSize {
|
|
return _attachments.fold(0, (sum, f) => sum + f.size);
|
|
}
|
|
|
|
String _formatSize(int bytes) {
|
|
if (bytes < 1024) return '$bytes B';
|
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
|
|
}
|
|
|
|
Future<void> _pickAttachments() async {
|
|
try {
|
|
final result = await FilePicker.pickFiles();
|
|
if (result == null) return;
|
|
final newFiles =
|
|
result.files.where((PlatformFile f) => f.path != null).toList();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_attachments.addAll(newFiles);
|
|
});
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Failed to pick files: $e')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _removeAttachment(int index) {
|
|
setState(() {
|
|
_attachments.removeAt(index);
|
|
});
|
|
}
|
|
|
|
String _serializeSyncLogs(List<SyncLogEntry> entries) {
|
|
final sb = StringBuffer();
|
|
for (final entry in entries.take(50)) {
|
|
sb.writeln('ID: ${entry.id}');
|
|
sb.writeln('Started: ${entry.startedAt.toIso8601String()}');
|
|
sb.writeln('Finished: ${entry.finishedAt.toIso8601String()}');
|
|
sb.writeln('Result: ${entry.result}');
|
|
if (entry.errorMessage != null) {
|
|
sb.writeln('Error: ${entry.errorMessage}');
|
|
}
|
|
if (entry.stackTrace != null) {
|
|
sb.writeln('StackTrace:\n${entry.stackTrace}');
|
|
}
|
|
sb.writeln('Protocol: ${entry.protocol}');
|
|
sb.writeln(
|
|
'Fetched: ${entry.emailsFetched}, Skipped: ${entry.emailsSkipped}',
|
|
);
|
|
if (entry.protocolLog != null) {
|
|
sb.writeln('Protocol Log:\n${entry.protocolLog}');
|
|
}
|
|
sb.writeln('---');
|
|
}
|
|
return sb.toString();
|
|
}
|
|
|
|
Future<void> _submitReport() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
final totalSize = _totalAttachmentSize;
|
|
if (totalSize > 20 * 1024 * 1024) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'Total attachments size exceeds the 20 MB limit. Please remove some files.',
|
|
),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(() => _submitting = true);
|
|
|
|
try {
|
|
final client = ref.read(httpClientProvider);
|
|
final uri = Uri.parse(_bugReportApiUrl);
|
|
final request = http.MultipartRequest('POST', uri);
|
|
|
|
// Description
|
|
request.fields['description'] = _descriptionController.text;
|
|
|
|
// Email Data if from email view
|
|
if (_attachedEmail != null) {
|
|
final emailMap = {
|
|
'id': _attachedEmail!.id,
|
|
'subject': _attachedEmail!.subject,
|
|
'from': _attachedEmail!.from.map((e) => e.toString()).toList(),
|
|
'date': _attachedEmail!.sentAt?.toIso8601String() ??
|
|
_attachedEmail!.receivedAt.toIso8601String(),
|
|
'preview': _attachedEmail!.preview,
|
|
};
|
|
request.fields['email_data'] = jsonEncode(emailMap);
|
|
}
|
|
|
|
// Contact Email
|
|
if (_includeEmail) {
|
|
request.fields['email'] = _emailController.text;
|
|
}
|
|
|
|
// About Info
|
|
PackageInfo? pkg;
|
|
try {
|
|
pkg = await _packageInfoFuture;
|
|
} catch (_) {}
|
|
final imapCount =
|
|
_accounts.where((a) => a.type == AccountType.imap).length;
|
|
final jmapCount =
|
|
_accounts.where((a) => a.type == AccountType.jmap).length;
|
|
|
|
if (!mounted) return;
|
|
final aboutInfo = buildAboutMarkdown(
|
|
context: context,
|
|
pkg: pkg,
|
|
imapCount: imapCount,
|
|
jmapCount: jmapCount,
|
|
deviceModel: _deviceModel,
|
|
);
|
|
request.fields['about_info'] = aboutInfo;
|
|
|
|
// Sync Log
|
|
if (_includeSyncLog && _selectedAccountId != null) {
|
|
final syncLogs = await ref
|
|
.read(syncLogRepositoryProvider)
|
|
.observeSyncLogs(_selectedAccountId!)
|
|
.first;
|
|
request.fields['sync_log'] = _serializeSyncLogs(syncLogs);
|
|
}
|
|
|
|
// Attachments
|
|
for (final file in _attachments) {
|
|
final multipartFile = await http.MultipartFile.fromPath(
|
|
'attachments[]',
|
|
file.path!,
|
|
filename: file.name,
|
|
);
|
|
request.files.add(multipartFile);
|
|
}
|
|
|
|
final streamedResponse = await client.send(request);
|
|
final response = await http.Response.fromStream(streamedResponse);
|
|
|
|
if (!mounted) return;
|
|
|
|
if (response.statusCode == 201) {
|
|
final resData = jsonDecode(response.body) as Map<String, dynamic>;
|
|
final reportId = resData['id'] as String;
|
|
_showSuccessDialog(reportId);
|
|
} else if (response.statusCode == 429) {
|
|
final retryAfter = response.headers['retry-after'] ?? '6';
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Rate limited. Please retry in $retryAfter seconds.'),
|
|
backgroundColor: Colors.orange,
|
|
),
|
|
);
|
|
} else {
|
|
String errorMsg =
|
|
'Failed to submit report. Server returned status: ${response.statusCode}';
|
|
try {
|
|
final resData = jsonDecode(response.body) as Map<String, dynamic>;
|
|
if (resData['error'] != null) {
|
|
errorMsg = resData['error'] as String;
|
|
}
|
|
} catch (_) {}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(errorMsg),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('An error occurred: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() => _submitting = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showSuccessDialog(String reportId) {
|
|
unawaited(
|
|
showDialog<void>(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) {
|
|
return AlertDialog(
|
|
title: const Text('Bug Report Submitted'),
|
|
content: SingleChildScrollView(
|
|
child: ListBody(
|
|
children: [
|
|
const Text('Thank you for helping us improve SharedInbox!'),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'Your Report ID is:\n$reportId',
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 12),
|
|
const Text(
|
|
'Your report is handled confidentially and has not been posted to the public issue tracker.',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
Navigator.of(context).pop(); // Dismiss dialog
|
|
context.pop(); // Go back to previous screen
|
|
},
|
|
child: const Text('Close'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final totalSize = _totalAttachmentSize;
|
|
const sizeLimit = 20 * 1024 * 1024;
|
|
final approachingLimit = totalSize > 15 * 1024 * 1024;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Report a Bug'),
|
|
),
|
|
body: _loadingEmail
|
|
? const Center(child: CircularProgressIndicator())
|
|
: Form(
|
|
key: _formKey,
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16.0),
|
|
children: [
|
|
// Confidentiality info card
|
|
Card(
|
|
elevation: 0,
|
|
color: theme.colorScheme.secondaryContainer
|
|
.withValues(alpha: 0.4),
|
|
shape: RoundedRectangleBorder(
|
|
side: BorderSide(
|
|
color:
|
|
theme.colorScheme.secondary.withValues(alpha: 0.4),
|
|
),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.lock_outline,
|
|
color: theme.colorScheme.secondary,
|
|
),
|
|
const SizedBox(width: 16),
|
|
const Expanded(
|
|
child: Text(
|
|
'Your report is handled confidentially and will not be posted to the public issue tracker.',
|
|
style: TextStyle(height: 1.3),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// Description Text Field
|
|
TextFormField(
|
|
controller: _descriptionController,
|
|
autofocus: true,
|
|
maxLines: 8,
|
|
minLines: 4,
|
|
decoration: const InputDecoration(
|
|
labelText: 'What went wrong?',
|
|
alignLabelWithHint: true,
|
|
border: OutlineInputBorder(),
|
|
helperText:
|
|
'Please describe the problem and how to reproduce it.',
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Please enter a description.';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// Email info chip if email is attached
|
|
if (_attachedEmail != null) ...[
|
|
Card(
|
|
elevation: 0,
|
|
color: theme.colorScheme.surfaceContainerHighest,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12.0,
|
|
vertical: 8.0,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.email_outlined,
|
|
size: 20,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 12),
|
|
const Expanded(
|
|
child: Text(
|
|
'The current email metadata will be attached automatically.',
|
|
style: TextStyle(fontSize: 13),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
|
|
// Attachments Section
|
|
Text(
|
|
'Attachments',
|
|
style: theme.textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
OutlinedButton.icon(
|
|
onPressed: _submitting ? null : _pickAttachments,
|
|
icon: const Icon(Icons.add_a_photo_outlined),
|
|
label: const Text('Add screenshots'),
|
|
),
|
|
const SizedBox(width: 16),
|
|
const Expanded(
|
|
child: Text(
|
|
'Screenshots help us understand the problem faster.',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_attachments.isNotEmpty) ...[
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
height: 48,
|
|
child: ListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
itemCount: _attachments.length,
|
|
itemBuilder: (context, index) {
|
|
final file = _attachments[index];
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 8.0),
|
|
child: InputChip(
|
|
label: Text(
|
|
'${file.name} (${_formatSize(file.size)})',
|
|
),
|
|
onDeleted: _submitting
|
|
? null
|
|
: () => _removeAttachment(index),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'Total Attachment Size: ${_formatSize(totalSize)} / ${_formatSize(sizeLimit)}',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: totalSize > sizeLimit
|
|
? Colors.red
|
|
: approachingLimit
|
|
? Colors.orange
|
|
: Colors.grey,
|
|
fontWeight: approachingLimit
|
|
? FontWeight.bold
|
|
: FontWeight.normal,
|
|
),
|
|
),
|
|
if (totalSize > sizeLimit) ...[
|
|
const SizedBox(width: 8),
|
|
const Icon(
|
|
Icons.error_outline,
|
|
size: 16,
|
|
color: Colors.red,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: 24),
|
|
|
|
// Email opt-in
|
|
CheckboxListTile(
|
|
title: const Text('Include my email for follow-up'),
|
|
value: _includeEmail,
|
|
onChanged: _submitting
|
|
? null
|
|
: (val) {
|
|
setState(() => _includeEmail = val ?? false);
|
|
},
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
if (_includeEmail) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 16.0),
|
|
child: TextFormField(
|
|
controller: _emailController,
|
|
keyboardType: TextInputType.emailAddress,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Contact Email Address',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
validator: (value) {
|
|
if (_includeEmail &&
|
|
(value == null || value.trim().isEmpty)) {
|
|
return 'Please enter an email address.';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
),
|
|
],
|
|
|
|
// Sync log opt-in
|
|
if (_selectedAccountId != null) ...[
|
|
CheckboxListTile(
|
|
title: const Text('Include recent sync log'),
|
|
subtitle: const Text(
|
|
'Helps diagnose connection and protocol issues.',
|
|
),
|
|
value: _includeSyncLog,
|
|
onChanged: _submitting
|
|
? null
|
|
: (val) {
|
|
setState(() => _includeSyncLog = val ?? false);
|
|
},
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
contentPadding: EdgeInsets.zero,
|
|
),
|
|
const SizedBox(height: 12),
|
|
],
|
|
|
|
// System info section
|
|
FutureBuilder<PackageInfo>(
|
|
future: _packageInfoFuture,
|
|
builder: (context, snapshot) {
|
|
final imapCount = _accounts
|
|
.where((a) => a.type == AccountType.imap)
|
|
.length;
|
|
final jmapCount = _accounts
|
|
.where((a) => a.type == AccountType.jmap)
|
|
.length;
|
|
final aboutMd = buildAboutMarkdown(
|
|
context: context,
|
|
pkg: snapshot.data,
|
|
imapCount: imapCount,
|
|
jmapCount: jmapCount,
|
|
deviceModel: _deviceModel,
|
|
);
|
|
return Card(
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(
|
|
side: BorderSide(
|
|
color: theme.dividerColor.withValues(alpha: 0.1),
|
|
),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: ExpansionTile(
|
|
title: const Text(
|
|
'System Info (attached automatically)',
|
|
style: TextStyle(fontSize: 14),
|
|
),
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: Align(
|
|
alignment: Alignment.topLeft,
|
|
child: MarkdownBody(data: aboutMd),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 32),
|
|
|
|
// Submit Button
|
|
FilledButton(
|
|
onPressed: _submitting ? null : _submitReport,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
|
child: _submitting
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: const Text(
|
|
'Send Bug Report',
|
|
style: TextStyle(fontSize: 16),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|