suggestly_flutter 0.2.2 copy "suggestly_flutter: ^0.2.2" to clipboard
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,
                    );
                  },
                ),
              ),
          ],
        ),
      ),
    );
  }
}
0
likes
160
points
13
downloads

Publisher

unverified uploader

Weekly Downloads

Themeable dialogs in flutter for Suggestly feedback flows: feature requests, bug reports, and ratings.

Homepage
Repository (GitHub)

Documentation

API reference

License

MIT (license)

Dependencies

file_selector, flutter, http, url_launcher

More

Packages that depend on suggestly_flutter