apktesting_reporter 0.1.0
apktesting_reporter: ^0.1.0 copied to clipboard
In-app bug reporting client for apktesting.com testing jobs.
example/lib/main.dart
import 'dart:io';
import 'dart:typed_data';
import 'package:apktesting_reporter/apktesting_reporter.dart';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
void main() {
runApp(const ReporterExampleApp());
}
class ReporterExampleApp extends StatelessWidget {
const ReporterExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'apktesting Reporter Example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const ReporterExamplePage(),
);
}
}
class ReporterExamplePage extends StatefulWidget {
const ReporterExamplePage({super.key});
@override
State<ReporterExamplePage> createState() => _ReporterExamplePageState();
}
class _ReporterExamplePageState extends State<ReporterExamplePage> {
final screenshotController = ApktestingScreenshotController();
final audioRecorder = AudioRecorder();
final titleController = TextEditingController();
final descriptionController = TextEditingController();
final jobId = const String.fromEnvironment('APKT_JOB_ID');
final idToken = const String.fromEnvironment('APKT_ID_TOKEN');
final apiBaseUrl = const String.fromEnvironment(
'APKT_API_BASE_URL',
defaultValue: 'http://localhost:3000',
);
bool isRecording = false;
Uint8List? voiceNote;
Future<void> toggleRecording(StateSetter setDialogState) async {
if (isRecording) {
final path = await audioRecorder.stop();
if (path != null) {
voiceNote = await File(path).readAsBytes();
}
isRecording = false;
setDialogState(() {});
return;
}
if (!await audioRecorder.hasPermission()) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Microphone permission is required for a voice note.',
),
),
);
}
return;
}
final directory = await getTemporaryDirectory();
final path = '${directory.path}/apktesting-voice-note.m4a';
await audioRecorder.start(
const RecordConfig(encoder: AudioEncoder.aacLc),
path: path,
);
isRecording = true;
voiceNote = null;
setDialogState(() {});
}
Future<void> openReporter() async {
titleController.clear();
descriptionController.clear();
voiceNote = null;
final screenshot = await screenshotController.capturePng();
if (!mounted) return;
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('Report a bug'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: titleController,
decoration: const InputDecoration(labelText: 'Bug title'),
),
TextField(
controller: descriptionController,
maxLines: 4,
decoration: const InputDecoration(labelText: 'What happened?'),
),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => toggleRecording(setDialogState),
icon: Icon(isRecording ? Icons.stop : Icons.mic),
label: Text(
isRecording ? 'Stop recording' : 'Record voice note',
),
),
if (voiceNote != null)
const Text(
'Voice note ready to upload.',
style: TextStyle(fontSize: 12),
),
],
),
actions: [
TextButton(
onPressed: () async {
if (isRecording) await audioRecorder.stop();
isRecording = false;
voiceNote = null;
if (context.mounted) Navigator.pop(context);
},
child: const Text('Cancel'),
),
FilledButton(
onPressed: isRecording ? null : () => submitReport(screenshot),
child: const Text('Submit'),
),
],
),
),
);
}
Future<void> submitReport(Uint8List? screenshot) async {
if (jobId.isEmpty ||
idToken.isEmpty ||
titleController.text.trim().isEmpty ||
descriptionController.text.trim().isEmpty) {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Configure APKT_JOB_ID, APKT_ID_TOKEN, and both report fields first.',
),
),
);
return;
}
final client = ApktestingReporterClient(
apiBaseUrl: apiBaseUrl,
idToken: idToken,
);
try {
await client.submit(
ApktestingReport(
jobId: jobId,
title: titleController.text.trim(),
description: descriptionController.text.trim(),
expectedResult: 'The feature works as designed.',
actualResult: descriptionController.text.trim(),
screenshot: screenshot,
audio: voiceNote,
audioFileName: 'voice-note.m4a',
),
);
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Report submitted.')));
}
} catch (error) {
if (mounted) {
Navigator.pop(context);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Report failed: $error')));
}
} finally {
client.close();
}
}
@override
void dispose() {
titleController.dispose();
descriptionController.dispose();
audioRecorder.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ApktestingShakeDetector(
onShake: openReporter,
child: screenshotController.captureSurface(
Scaffold(
appBar: AppBar(title: const Text('Reporter SDK example')),
body: const Center(
child: Text('Open the reporter button to submit a test issue.'),
),
floatingActionButton: ApktestingReporterButton(
onSubmit: openReporter,
),
),
),
);
}
}