suggestly_flutter 0.2.2
suggestly_flutter: ^0.2.2 copied to clipboard
Themeable dialogs in flutter for Suggestly feedback flows: feature requests, bug reports, and ratings.
example/lib/main.dart
// ignore_for_file: invalid_use_of_visible_for_testing_member
import 'package:flutter/material.dart';
import 'package:suggestly_flutter/suggestly_flutter.dart';
import 'demo_suggestly_client.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const SuggestlyHostApp());
}
class SuggestlyHostApp extends StatelessWidget {
const SuggestlyHostApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Suggestly Popup Host',
theme: ThemeData(
colorSchemeSeed: Colors.deepPurple,
useMaterial3: true,
),
home: const HostHomePage(),
);
}
}
enum BackendMode { demo, live }
class HostHomePage extends StatefulWidget {
const HostHomePage({super.key});
@override
State<HostHomePage> createState() => _HostHomePageState();
}
class _HostHomePageState extends State<HostHomePage> {
final TextEditingController _apiKeyController = TextEditingController();
final TextEditingController _emailController =
TextEditingController(text: 'demo@suggestly.ai');
final TextEditingController _nameController =
TextEditingController(text: 'Demo Reviewer');
final TextEditingController _userIdController =
TextEditingController(text: 'demo-1');
final DemoSuggestlyClient _demoClient = DemoSuggestlyClient();
BackendMode _mode = BackendMode.demo;
bool _initialising = false;
bool _initialised = false;
bool _allowBugReports = true;
bool _allowFeatureRequests = true;
bool _allowRatings = true;
String? _statusMessage;
String? _errorMessage;
ApplicationSummary? _applicationSummary;
SuggestlyUser? _userDetails;
final List<String> _log = <String>[];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_initialize();
}
});
}
@override
void dispose() {
_apiKeyController.dispose();
_emailController.dispose();
_nameController.dispose();
_userIdController.dispose();
super.dispose();
}
Future<void> _initialize() async {
if (_initialising) {
return;
}
final BackendMode mode = _mode;
if (mode == BackendMode.live &&
_apiKeyController.text.trim().isEmpty) {
_showSnackBar('Enter an API key to use the live backend.');
setState(() {
_statusMessage = null;
_errorMessage = 'API key required for live backend.';
});
return;
}
setState(() {
_initialising = true;
_statusMessage = null;
_errorMessage = null;
_applicationSummary = null;
});
try {
Suggestly.dispose();
if (mode == BackendMode.demo) {
_demoClient.updateEligibility(
bugReports: _allowBugReports,
featureRequests: _allowFeatureRequests,
ratings: _allowRatings,
);
Suggestly.debugOverrideClient(_demoClient);
} else {
final String apiKey = _apiKeyController.text.trim();
await Suggestly.initialize(apiKey: apiKey);
}
final ApplicationSummary summary =
await Suggestly.getApplicationSummary(forceRefresh: true);
if (!mounted) {
return;
}
setState(() {
_initialised = true;
_applicationSummary = summary;
_statusMessage =
'Initialised ${_describeMode(mode)} backend for ${summary.name}.';
_errorMessage = null;
});
_addLogEntry(
'Initialised ${_describeMode(mode)} backend for ${summary.name}',
);
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_initialised = false;
_errorMessage = error.toString();
_statusMessage = null;
});
final String message = 'Initialisation failed: $error';
_addLogEntry(message);
_showSnackBar(message);
} finally {
if (mounted) {
setState(() {
_initialising = false;
});
}
}
}
Future<void> _ensureUser() async {
if (!_requireInitialisation()) {
return;
}
final String email = _emailController.text.trim();
final String name = _nameController.text.trim();
if (email.isEmpty) {
_showSnackBar('Enter an email address first.');
return;
}
try {
final String userId = await Suggestly.ensureUserExists(
email: email,
name: name.isEmpty ? null : name,
);
if (!mounted) {
return;
}
setState(() {
_userIdController.text = userId;
_statusMessage = 'Ensured user $userId';
_errorMessage = null;
});
_addLogEntry('Ensured user $userId for $email');
await _loadUserDetails(refresh: true);
} catch (error) {
if (!mounted) {
return;
}
final String message = 'Failed to ensure user: $error';
setState(() {
_errorMessage = message;
});
_addLogEntry(message);
_showSnackBar(message);
}
}
Future<void> _loadUserDetails({bool refresh = false}) async {
if (!_requireInitialisation(requireUserId: true)) {
return;
}
final String userId = _userIdController.text.trim();
try {
final SuggestlyUser? user = await Suggestly.getUserDetails(
userId: userId,
forceRefresh: refresh,
);
if (!mounted) {
return;
}
setState(() {
_userDetails = user;
if (user != null) {
if (user.email != null && user.email!.isNotEmpty) {
_emailController.text = user.email!;
}
if (user.name != null && user.name!.isNotEmpty) {
_nameController.text = user.name!;
}
_statusMessage = 'Loaded user details for $userId';
_errorMessage = null;
} else {
_statusMessage = 'No user record found for $userId';
_errorMessage = null;
}
});
_addLogEntry(
user != null
? 'Loaded user details for $userId'
: 'User $userId not found',
);
} catch (error) {
if (!mounted) {
return;
}
final String message = 'Failed to load user: $error';
setState(() {
_errorMessage = message;
});
_addLogEntry(message);
_showSnackBar(message);
}
}
Future<void> _openFeatureRequest() async {
if (!_requireInitialisation(requireUserId: true)) {
return;
}
FocusScope.of(context).unfocus();
final String userId = _userIdController.text.trim();
try {
final SuggestlyPopupResult<FeedbackSubmissionResult> result =
await SuggestlyFeatureRequestPopup.show(
context,
title: 'Request a feature',
message: 'Share what would make this application better.',
userId: userId,
submitLabel: 'Submit request',
cancelLabel: 'Cancel',
);
if (!mounted) {
return;
}
_handlePopupResult('Feature request', result);
} catch (error) {
_reportException('Feature request', error);
}
}
Future<void> _openBugReport() async {
if (!_requireInitialisation(requireUserId: true)) {
return;
}
FocusScope.of(context).unfocus();
final String userId = _userIdController.text.trim();
try {
final SuggestlyPopupResult<FeedbackSubmissionResult> result =
await SuggestlyBugReportPopup.show(
context,
title: 'Report a bug',
message: 'Describe the issue you ran into.',
userId: userId,
submitLabel: 'Submit report',
cancelLabel: 'Cancel',
);
if (!mounted) {
return;
}
_handlePopupResult('Bug report', result);
} catch (error) {
_reportException('Bug report', error);
}
}
Future<void> _openRating() async {
if (!_requireInitialisation(requireUserId: true)) {
return;
}
FocusScope.of(context).unfocus();
final String userId = _userIdController.text.trim();
try {
final SuggestlyPopupResult<FeedbackSubmissionResult> result =
await SuggestlyRatingPopup.show(
context,
title: 'Rate your experience',
message: 'Stars help us prioritise improvements.',
userId: userId,
submitLabel: 'Submit rating',
cancelLabel: 'Cancel',
ratingLabelBuilder: (int rating) =>
'$rating star${rating == 1 ? '' : 's'}',
);
if (!mounted) {
return;
}
_handlePopupResult('Rating', result);
} catch (error) {
_reportException('Rating', error);
}
}
bool _requireInitialisation({bool requireUserId = false}) {
if (!_initialised) {
_showSnackBar('Initialise Suggestly before continuing.');
if (mounted) {
setState(() {
_statusMessage = null;
_errorMessage = 'Suggestly is not initialised.';
});
}
return false;
}
if (requireUserId && _userIdController.text.trim().isEmpty) {
_showSnackBar('Enter a user ID or ensure the user first.');
if (mounted) {
setState(() {
_errorMessage = 'User ID required.';
});
}
return false;
}
return true;
}
void _handlePopupResult(
String label,
SuggestlyPopupResult<FeedbackSubmissionResult> result,
) {
String message;
if (result.didSubmit) {
final FeedbackSubmissionResult? submission = result.value;
final String detail = submission?.message ?? 'Submission saved';
message = '$label submitted ($detail).';
} else if (result.hasError) {
final String detail =
result.errorMessage ?? result.errorDetails?.toString() ?? 'Unknown';
message = '$label failed: $detail';
} else if (!result.wasShown) {
final String detail = result.errorMessage ?? 'Dialog was not shown.';
message = '$label dialog was not shown (${detail.toLowerCase()}).';
} else {
message = '$label cancelled by user.';
}
setState(() {
if (result.hasError) {
_errorMessage =
result.errorMessage ?? result.errorDetails?.toString();
} else {
_errorMessage = null;
}
_statusMessage = message;
});
final String extra = result.value != null
? ' Payload: ${result.value!.raw}'
: result.hasError && result.errorDetails != null
? ' Details: ${result.errorDetails}'
: '';
_addLogEntry('$message$extra');
}
void _reportException(String label, Object error) {
final String message = '$label threw an exception: $error';
setState(() {
_statusMessage = null;
_errorMessage = message;
});
_addLogEntry(message);
_showSnackBar(message);
}
void _addLogEntry(String entry) {
final String timestamp = TimeOfDay.now().format(context);
setState(() {
_log.insert(0, '$timestamp • $entry');
});
}
void _clearLog() {
setState(() {
_log.clear();
});
}
void _showSnackBar(String message) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
}
String _describeMode(BackendMode mode) {
return mode == BackendMode.demo ? 'demo' : 'live';
}
@override
Widget build(BuildContext context) {
final ThemeData theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Suggestly Popup Host'),
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
_buildBackendSection(theme),
const SizedBox(height: 24),
_buildUserSection(theme),
const SizedBox(height: 24),
_buildActionsSection(theme),
const SizedBox(height: 24),
_buildStatusSection(theme),
const SizedBox(height: 24),
_buildLogSection(theme),
],
),
),
);
}
Widget _buildBackendSection(ThemeData theme) {
final bool liveMode = _mode == BackendMode.live;
return Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('Backend', style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
SegmentedButton<BackendMode>(
segments: const <ButtonSegment<BackendMode>>[
ButtonSegment<BackendMode>(
value: BackendMode.demo,
label: Text('Demo'),
icon: Icon(Icons.science_outlined),
),
ButtonSegment<BackendMode>(
value: BackendMode.live,
label: Text('Live'),
icon: Icon(Icons.cloud_outlined),
),
],
selected: <BackendMode>{_mode},
onSelectionChanged: (Set<BackendMode> selection) {
if (selection.isEmpty) {
return;
}
final BackendMode next = selection.first;
if (next == _mode) {
return;
}
setState(() {
_mode = next;
_initialised = false;
_applicationSummary = null;
_statusMessage =
'Press Initialise to configure the ${_describeMode(next)} backend.';
_errorMessage = null;
});
},
),
const SizedBox(height: 16),
if (liveMode) ...<Widget>[
TextField(
controller: _apiKeyController,
decoration: const InputDecoration(
labelText: 'API key',
hintText: 'sk_live_...',
),
obscureText: true,
autocorrect: false,
enableSuggestions: false,
),
const SizedBox(height: 12),
Text(
'Paste a Suggestly API key with reviewer access. '
'Use demo mode if you are experimenting locally.',
style: theme.textTheme.bodySmall,
),
] else ...<Widget>[
SwitchListTile.adaptive(
title: const Text('Allow bug reports'),
value: _allowBugReports,
onChanged: (bool value) {
setState(() {
_allowBugReports = value;
});
_demoClient.updateEligibility(bugReports: value);
},
),
SwitchListTile.adaptive(
title: const Text('Allow feature requests'),
value: _allowFeatureRequests,
onChanged: (bool value) {
setState(() {
_allowFeatureRequests = value;
});
_demoClient.updateEligibility(featureRequests: value);
},
),
SwitchListTile.adaptive(
title: const Text('Allow ratings'),
value: _allowRatings,
onChanged: (bool value) {
setState(() {
_allowRatings = value;
});
_demoClient.updateEligibility(ratings: value);
},
),
],
const SizedBox(height: 16),
Row(
children: <Widget>[
ElevatedButton.icon(
onPressed: _initialising ? null : _initialize,
icon: _initialising
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.play_arrow_rounded),
label: Text(
_initialising ? 'Initialising…' : 'Initialise',
),
),
const SizedBox(width: 12),
if (_initialised)
Chip(
label: Text(
'${_describeMode(_mode)} ready',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onPrimary,
),
),
backgroundColor: theme.colorScheme.primary,
),
],
),
const SizedBox(height: 16),
_buildApplicationSummary(theme),
],
),
),
);
}
Widget _buildApplicationSummary(ThemeData theme) {
final ApplicationSummary? summary = _applicationSummary;
if (summary == null) {
return Text(
'Application summary will appear here after initialisation.',
style: theme.textTheme.bodySmall,
);
}
return ListTile(
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
child: Text(
summary.name.isNotEmpty ? summary.name[0].toUpperCase() : '?',
),
),
title: Text(summary.name),
subtitle: Text(
'ID: ${summary.id}\n'
'Status: ${summary.status ?? 'unknown'} • Visibility: ${summary.publishedVisibility ?? 'unknown'}',
),
);
}
Widget _buildUserSection(ThemeData theme) {
return Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('User setup', style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
TextField(
controller: _emailController,
decoration: const InputDecoration(
labelText: 'User email',
hintText: 'reviewer@example.com',
),
keyboardType: TextInputType.emailAddress,
autocorrect: false,
),
const SizedBox(height: 12),
TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Display name (optional)',
),
),
const SizedBox(height: 12),
TextField(
controller: _userIdController,
decoration: const InputDecoration(
labelText: 'User ID',
hintText: 'suggestly-user-id',
),
autocorrect: false,
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
ElevatedButton.icon(
onPressed: _initialising ? null : _ensureUser,
icon: const Icon(Icons.person_add_alt_1),
label: const Text('Ensure / create user'),
),
OutlinedButton.icon(
onPressed: () => _loadUserDetails(refresh: true),
icon: const Icon(Icons.refresh),
label: const Text('Load details'),
),
],
),
const SizedBox(height: 12),
_buildUserDetails(theme),
],
),
),
);
}
Widget _buildUserDetails(ThemeData theme) {
final SuggestlyUser? user = _userDetails;
if (user == null) {
return Text(
'User details populate once loaded from Suggestly.',
style: theme.textTheme.bodySmall,
);
}
final List<Widget> rows = <Widget>[
Text('ID: ${user.id}', style: theme.textTheme.bodySmall),
];
if (user.email != null) {
rows.add(Text('Email: ${user.email}', style: theme.textTheme.bodySmall));
}
if (user.name != null) {
rows.add(Text('Name: ${user.name}', style: theme.textTheme.bodySmall));
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: rows,
);
}
Widget _buildActionsSection(ThemeData theme) {
return Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('Popups', style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: <Widget>[
FilledButton.icon(
onPressed: _openFeatureRequest,
icon: const Icon(Icons.lightbulb_outline),
label: const Text('Feature request'),
),
FilledButton.icon(
onPressed: _openBugReport,
icon: const Icon(Icons.bug_report_outlined),
label: const Text('Bug report'),
),
FilledButton.icon(
onPressed: _openRating,
icon: const Icon(Icons.star_border_rounded),
label: const Text('Rating'),
),
],
),
],
),
),
);
}
Widget _buildStatusSection(ThemeData theme) {
final List<Widget> messages = <Widget>[];
if (_statusMessage != null) {
messages.add(
Row(
children: <Widget>[
Icon(Icons.check_circle,
color: theme.colorScheme.primary, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
_statusMessage!,
style: theme.textTheme.bodyMedium,
),
),
],
),
);
}
if (_errorMessage != null) {
messages.add(
Row(
children: <Widget>[
Icon(Icons.error_outline,
color: theme.colorScheme.error, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
),
),
],
),
);
}
if (messages.isEmpty) {
messages.add(
Text(
'Status messages from Suggestly will appear here.',
style: theme.textTheme.bodySmall,
),
);
}
return Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: messages,
),
),
);
}
Widget _buildLogSection(ThemeData theme) {
return Card(
clipBehavior: Clip.antiAlias,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text('Activity log', style: theme.textTheme.titleMedium),
IconButton(
tooltip: 'Clear log',
onPressed: _log.isEmpty ? null : _clearLog,
icon: const Icon(Icons.delete_sweep_outlined),
),
],
),
const SizedBox(height: 12),
if (_log.isEmpty)
Text(
'Interact with the host to collect log entries.',
style: theme.textTheme.bodySmall,
)
else
SizedBox(
height: 200,
child: ListView.separated(
itemCount: _log.length,
separatorBuilder: (_, __) => const Divider(height: 12),
itemBuilder: (BuildContext context, int index) {
return Text(
_log[index],
style: theme.textTheme.bodySmall,
);
},
),
),
],
),
),
);
}
}