feat(flutter): user-facing requests view + cancel (#356)
Closes the cleanest remaining #356 gap — Discover submits Lidarr requests but had no surface for users to view or cancel their own. Admin had it; user didn't. - RequestsApi at lib/api/endpoints/requests.dart — listMine() + cancel(id). Same /api/requests endpoint the web /requests page uses; identical AdminRequest wire shape so the existing model is reused (just augmented with matched_*_id fields for the "Listen" CTA on completed rows). - MyRequestsController mirrors the web's auto-poll (#369 piece already shipped server-side / web-side): 12s refresh while any row is mid-ingest (status='approved'), stops on settle. Riverpod Timer in build() that's cancelled in onDispose. - RequestsScreen with kind/status pills, ingest progress copy, Cancel (with confirm dialog) and Listen CTAs per row state. - Route /requests under the shell. Entry point in settings between Profile and Appearance — "My requests". - Widget tests cover empty / pending / completed / rejected states + the Cancel-confirm dialog. Out of scope this slice: Discover-screen "View your requests" CTA after submitting a new request. Reasonable follow-up but doesn't block the management loop. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../models/admin_request.dart';
|
||||
|
||||
/// User-side requests API — `/api/requests`. Server scopes results to
|
||||
/// the caller; admins see only their own here, not all users'. The
|
||||
/// admin-cross-user view lives in `/api/admin/requests` (AdminRequestsApi).
|
||||
///
|
||||
/// Wire shape is identical to the admin endpoint, so the same
|
||||
/// AdminRequest model is reused.
|
||||
class RequestsApi {
|
||||
RequestsApi(this._dio);
|
||||
final Dio _dio;
|
||||
|
||||
/// GET /api/requests — caller's own requests, all statuses.
|
||||
Future<List<AdminRequest>> listMine() async {
|
||||
final r = await _dio.get<List<dynamic>>('/api/requests');
|
||||
final raw = r.data ?? const [];
|
||||
return raw
|
||||
.map((e) => AdminRequest.fromJson((e as Map).cast<String, dynamic>()))
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
/// DELETE /api/requests/{id} — cancel a pending request. Server
|
||||
/// returns the cancelled row body (not 204) so the caller can patch
|
||||
/// local state without a refetch.
|
||||
Future<AdminRequest> cancel(String id) async {
|
||||
final r = await _dio.delete<Map<String, dynamic>>('/api/requests/$id');
|
||||
return AdminRequest.fromJson(r.data ?? const {});
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,9 @@ class AdminRequest {
|
||||
this.notes,
|
||||
required this.importedAlbumCount,
|
||||
required this.importedTrackCount,
|
||||
this.matchedTrackId,
|
||||
this.matchedAlbumId,
|
||||
this.matchedArtistId,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -38,6 +41,13 @@ class AdminRequest {
|
||||
final int importedAlbumCount;
|
||||
final int importedTrackCount;
|
||||
|
||||
/// Set when the ingest matched into the local library. The user-side
|
||||
/// "Listen" CTA on a completed request links to whichever id is set
|
||||
/// (most-specific first: track → album → artist).
|
||||
final String? matchedTrackId;
|
||||
final String? matchedAlbumId;
|
||||
final String? matchedArtistId;
|
||||
|
||||
/// Display label depending on the kind of request — what the user
|
||||
/// asked for. For an album request it's the album title; for an
|
||||
/// artist request it's the artist name; etc.
|
||||
@@ -60,5 +70,8 @@ class AdminRequest {
|
||||
notes: j['notes'] as String?,
|
||||
importedAlbumCount: (j['imported_album_count'] as int?) ?? 0,
|
||||
importedTrackCount: (j['imported_track_count'] as int?) ?? 0,
|
||||
matchedTrackId: j['matched_track_id'] as String?,
|
||||
matchedAlbumId: j['matched_album_id'] as String?,
|
||||
matchedArtistId: j['matched_artist_id'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../api/endpoints/requests.dart';
|
||||
import '../library/library_providers.dart' show dioProvider;
|
||||
import '../models/admin_request.dart';
|
||||
|
||||
final requestsApiProvider = FutureProvider<RequestsApi>((ref) async {
|
||||
return RequestsApi(await ref.watch(dioProvider.future));
|
||||
});
|
||||
|
||||
/// Mirrors the web's createMyRequestsQuery auto-poll (#369): refresh
|
||||
/// every 12s while any row is mid-ingest (status='approved'), stop when
|
||||
/// all rows settle. Polls regardless of app foreground/background — a
|
||||
/// future enhancement could pause via WidgetsBindingObserver, but for
|
||||
/// v1 the small extra refresh is acceptable.
|
||||
class MyRequestsController extends AsyncNotifier<List<AdminRequest>> {
|
||||
static const Duration _pollInterval = Duration(seconds: 12);
|
||||
|
||||
Timer? _pollTimer;
|
||||
|
||||
@override
|
||||
Future<List<AdminRequest>> build() async {
|
||||
ref.onDispose(() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
});
|
||||
final api = await ref.watch(requestsApiProvider.future);
|
||||
final rows = await api.listMine();
|
||||
_maybeStartPolling(rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
void _maybeStartPolling(List<AdminRequest> rows) {
|
||||
_pollTimer?.cancel();
|
||||
if (rows.any((r) => r.status == 'approved')) {
|
||||
_pollTimer = Timer.periodic(_pollInterval, (_) {
|
||||
ref.invalidateSelf();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Optimistic remove + REST cancel. Restores the row on failure so
|
||||
/// the user can retry — silent failure would lie about success.
|
||||
Future<void> cancel(String id) async {
|
||||
final api = await ref.read(requestsApiProvider.future);
|
||||
final current = state.value ?? const <AdminRequest>[];
|
||||
state = AsyncData(current.where((r) => r.id != id).toList());
|
||||
try {
|
||||
await api.cancel(id);
|
||||
} catch (e, st) {
|
||||
state = AsyncData(current);
|
||||
Error.throwWithStackTrace(e, st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final myRequestsProvider =
|
||||
AsyncNotifierProvider<MyRequestsController, List<AdminRequest>>(
|
||||
MyRequestsController.new);
|
||||
@@ -0,0 +1,241 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../models/admin_request.dart';
|
||||
import '../shared/widgets/main_app_bar_actions.dart';
|
||||
import '../theme/theme_extension.dart';
|
||||
import 'requests_provider.dart';
|
||||
|
||||
class RequestsScreen extends ConsumerWidget {
|
||||
const RequestsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final requests = ref.watch(myRequestsProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: fs.obsidian,
|
||||
appBar: AppBar(
|
||||
backgroundColor: fs.obsidian,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: fs.parchment),
|
||||
onPressed: () => context.pop(),
|
||||
),
|
||||
title: Text('Your requests', style: TextStyle(color: fs.parchment)),
|
||||
actions: const [MainAppBarActions(currentRoute: '/requests')],
|
||||
),
|
||||
body: SafeArea(
|
||||
child: requests.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('$e', style: TextStyle(color: fs.error)),
|
||||
),
|
||||
),
|
||||
data: (rows) {
|
||||
if (rows.isEmpty) {
|
||||
return Center(
|
||||
child: Text('Nothing requested yet.',
|
||||
style: TextStyle(color: fs.ash)),
|
||||
);
|
||||
}
|
||||
final notifier = ref.read(myRequestsProvider.notifier);
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => ref.refresh(myRequestsProvider.future),
|
||||
child: ListView.separated(
|
||||
itemCount: rows.length,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
separatorBuilder: (_, __) => Divider(color: fs.iron, height: 1),
|
||||
itemBuilder: (_, i) => _RequestRow(
|
||||
key: Key('request_row_${rows[i].id}'),
|
||||
request: rows[i],
|
||||
onCancel: () => notifier.cancel(rows[i].id),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RequestRow extends StatelessWidget {
|
||||
const _RequestRow({
|
||||
super.key,
|
||||
required this.request,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
final AdminRequest request;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final href = _listenHref(request);
|
||||
return ListTile(
|
||||
leading: _kindAvatar(fs),
|
||||
title: Text(
|
||||
request.displayName,
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
_KindPill(label: request.kind),
|
||||
const SizedBox(width: 6),
|
||||
_StatusPill(status: request.status),
|
||||
],
|
||||
),
|
||||
if (request.importedAlbumCount > 0 ||
|
||||
request.importedTrackCount > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_ingestProgressText(request),
|
||||
style: TextStyle(color: fs.accent, fontSize: 13),
|
||||
),
|
||||
],
|
||||
if (request.status == 'rejected' && (request.notes ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(request.notes!, style: TextStyle(color: fs.ash, fontSize: 13)),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: _trailing(context, fs, href),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _kindAvatar(FabledSwordTheme fs) {
|
||||
final icon = switch (request.kind) {
|
||||
'artist' => Icons.album,
|
||||
'album' => Icons.library_music,
|
||||
_ => Icons.music_note,
|
||||
};
|
||||
return CircleAvatar(
|
||||
backgroundColor: fs.iron,
|
||||
child: Icon(icon, size: 20, color: fs.ash),
|
||||
);
|
||||
}
|
||||
|
||||
Widget? _trailing(BuildContext context, FabledSwordTheme fs, String? href) {
|
||||
if (request.status == 'pending') {
|
||||
return TextButton.icon(
|
||||
onPressed: () async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Cancel request?'),
|
||||
content: Text('Cancel "${request.displayName}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Keep'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.oxblood),
|
||||
child: const Text('Cancel request'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) onCancel();
|
||||
},
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
label: const Text('Cancel'),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.ash),
|
||||
);
|
||||
}
|
||||
if (request.status == 'completed' && href != null) {
|
||||
return TextButton.icon(
|
||||
onPressed: () => context.push(href),
|
||||
icon: const Icon(Icons.play_arrow, size: 16),
|
||||
label: const Text('Listen'),
|
||||
style: TextButton.styleFrom(foregroundColor: fs.accent),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _ingestProgressText(AdminRequest r) {
|
||||
if (r.kind == 'artist') {
|
||||
final albums = '${r.importedAlbumCount} '
|
||||
'${r.importedAlbumCount == 1 ? 'album' : 'albums'}';
|
||||
final tracks = '${r.importedTrackCount} '
|
||||
'${r.importedTrackCount == 1 ? 'track' : 'tracks'} ingested';
|
||||
return '$albums · $tracks';
|
||||
}
|
||||
if (r.kind == 'album') {
|
||||
return '${r.importedTrackCount} '
|
||||
'${r.importedTrackCount == 1 ? 'track' : 'tracks'} ingested';
|
||||
}
|
||||
return 'Track ingested';
|
||||
}
|
||||
|
||||
String? _listenHref(AdminRequest r) {
|
||||
if (r.matchedTrackId != null) return '/albums/${r.matchedAlbumId ?? r.matchedTrackId}';
|
||||
if (r.matchedAlbumId != null) return '/albums/${r.matchedAlbumId}';
|
||||
if (r.matchedArtistId != null) return '/artists/${r.matchedArtistId}';
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class _KindPill extends StatelessWidget {
|
||||
const _KindPill({required this.label});
|
||||
final String label;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: fs.accent.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: fs.accent, fontSize: 11),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusPill extends StatelessWidget {
|
||||
const _StatusPill({required this.status});
|
||||
final String status;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
final color = switch (status) {
|
||||
'pending' => fs.ash,
|
||||
'approved' => fs.bronze,
|
||||
'completed' => fs.moss,
|
||||
'rejected' => fs.oxblood,
|
||||
'failed' => fs.oxblood,
|
||||
_ => fs.ash,
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
status,
|
||||
style: TextStyle(color: color, fontSize: 11),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ class SettingsScreen extends ConsumerWidget {
|
||||
children: const [
|
||||
_ProfileSection(),
|
||||
_Divider(),
|
||||
_RequestsSection(),
|
||||
_Divider(),
|
||||
_AppearanceSection(),
|
||||
_Divider(),
|
||||
StorageSection(),
|
||||
@@ -94,6 +96,33 @@ class _SectionHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _RequestsSection extends StatelessWidget {
|
||||
const _RequestsSection();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
||||
return ListTile(
|
||||
key: const Key('settings_requests_card'),
|
||||
leading: Icon(Icons.queue_music, color: fs.parchment),
|
||||
title: Text(
|
||||
'My requests',
|
||||
style: TextStyle(
|
||||
color: fs.parchment,
|
||||
fontFamily: 'Fraunces',
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
"Track what you've asked Minstrel to add",
|
||||
style: TextStyle(color: fs.ash),
|
||||
),
|
||||
trailing: Icon(Icons.chevron_right, color: fs.ash),
|
||||
onTap: () => context.push('/requests'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders an "Admin" entry only when the current profile has
|
||||
/// `is_admin = true`. Returns an empty SizedBox otherwise so the
|
||||
/// const-children layout above stays valid.
|
||||
|
||||
@@ -15,6 +15,7 @@ import '../player/player_bar.dart';
|
||||
import '../player/queue_screen.dart';
|
||||
import '../playlists/playlist_detail_screen.dart';
|
||||
import '../playlists/playlists_list_screen.dart';
|
||||
import '../requests/requests_screen.dart';
|
||||
import '../search/search_screen.dart';
|
||||
import '../settings/settings_screen.dart';
|
||||
import '../admin/admin_landing_screen.dart';
|
||||
@@ -75,6 +76,7 @@ GoRouter buildRouter(Ref ref) {
|
||||
path: '/playlists/:id',
|
||||
builder: (_, s) => PlaylistDetailScreen(id: s.pathParameters['id']!),
|
||||
),
|
||||
GoRoute(path: '/requests', builder: (_, __) => const RequestsScreen()),
|
||||
GoRoute(path: '/admin', builder: (_, __) => const AdminLandingScreen()),
|
||||
GoRoute(path: '/admin/requests', builder: (_, __) => const AdminRequestsScreen()),
|
||||
GoRoute(path: '/admin/quarantine', builder: (_, __) => const AdminQuarantineScreen()),
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:minstrel/auth/auth_provider.dart';
|
||||
import 'package:minstrel/models/admin_request.dart';
|
||||
import 'package:minstrel/models/user.dart';
|
||||
import 'package:minstrel/requests/requests_provider.dart';
|
||||
import 'package:minstrel/requests/requests_screen.dart';
|
||||
import 'package:minstrel/theme/theme_data.dart';
|
||||
|
||||
class _StubAuth extends AuthController {
|
||||
@override
|
||||
Future<User?> build() async =>
|
||||
const User(id: 'u1', username: 'alice', isAdmin: false);
|
||||
}
|
||||
|
||||
class _StubRequests extends MyRequestsController {
|
||||
_StubRequests(this._initial);
|
||||
final List<AdminRequest> _initial;
|
||||
@override
|
||||
Future<List<AdminRequest>> build() async => _initial;
|
||||
}
|
||||
|
||||
const _pendingAlbum = AdminRequest(
|
||||
id: 'r1',
|
||||
userId: 'u1',
|
||||
status: 'pending',
|
||||
kind: 'album',
|
||||
artistName: 'Aphex Twin',
|
||||
albumTitle: 'Drukqs',
|
||||
trackTitle: null,
|
||||
requestedAt: '2026-05-08T00:00:00Z',
|
||||
decidedAt: null,
|
||||
notes: null,
|
||||
importedAlbumCount: 0,
|
||||
importedTrackCount: 0,
|
||||
);
|
||||
|
||||
const _completedTrackWithMatch = AdminRequest(
|
||||
id: 'r2',
|
||||
userId: 'u1',
|
||||
status: 'completed',
|
||||
kind: 'track',
|
||||
artistName: 'Boards of Canada',
|
||||
albumTitle: 'Geogaddi',
|
||||
trackTitle: 'Roygbiv',
|
||||
requestedAt: '2026-05-01T00:00:00Z',
|
||||
decidedAt: '2026-05-02T00:00:00Z',
|
||||
notes: null,
|
||||
importedAlbumCount: 1,
|
||||
importedTrackCount: 1,
|
||||
matchedTrackId: 't1',
|
||||
matchedAlbumId: 'a1',
|
||||
);
|
||||
|
||||
const _rejectedWithNotes = AdminRequest(
|
||||
id: 'r3',
|
||||
userId: 'u1',
|
||||
status: 'rejected',
|
||||
kind: 'artist',
|
||||
artistName: 'Some Artist',
|
||||
albumTitle: null,
|
||||
trackTitle: null,
|
||||
requestedAt: '2026-05-01T00:00:00Z',
|
||||
decidedAt: '2026-05-02T00:00:00Z',
|
||||
notes: 'Not in MusicBrainz',
|
||||
importedAlbumCount: 0,
|
||||
importedTrackCount: 0,
|
||||
);
|
||||
|
||||
Widget _harness(List<AdminRequest> requests) => ProviderScope(
|
||||
overrides: [
|
||||
authControllerProvider.overrideWith(() => _StubAuth()),
|
||||
myRequestsProvider.overrideWith(() => _StubRequests(requests)),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: buildThemeData(),
|
||||
home: const RequestsScreen(),
|
||||
),
|
||||
);
|
||||
|
||||
void main() {
|
||||
testWidgets('renders empty state when no requests', (t) async {
|
||||
await t.pumpWidget(_harness(const []));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.text('Nothing requested yet.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders pending row with Cancel CTA', (t) async {
|
||||
await t.pumpWidget(_harness(const [_pendingAlbum]));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.byKey(const Key('request_row_r1')), findsOneWidget);
|
||||
expect(find.text('Drukqs'), findsOneWidget);
|
||||
expect(find.text('Cancel'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders completed row with Listen CTA', (t) async {
|
||||
await t.pumpWidget(_harness(const [_completedTrackWithMatch]));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.text('Roygbiv'), findsOneWidget);
|
||||
expect(find.text('Listen'), findsOneWidget);
|
||||
// Ingest progress copy
|
||||
expect(find.text('Track ingested'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders rejected row with notes; no CTA', (t) async {
|
||||
await t.pumpWidget(_harness(const [_rejectedWithNotes]));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.text('Some Artist'), findsOneWidget);
|
||||
expect(find.text('Not in MusicBrainz'), findsOneWidget);
|
||||
expect(find.text('Cancel'), findsNothing);
|
||||
expect(find.text('Listen'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('Cancel button opens confirm dialog', (t) async {
|
||||
await t.pumpWidget(_harness(const [_pendingAlbum]));
|
||||
await t.pumpAndSettle();
|
||||
await t.tap(find.text('Cancel'));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.text('Cancel request?'), findsOneWidget);
|
||||
expect(find.text('Cancel "Drukqs"?'), findsOneWidget);
|
||||
// Dismiss with Keep
|
||||
await t.tap(find.text('Keep'));
|
||||
await t.pumpAndSettle();
|
||||
expect(find.text('Cancel request?'), findsNothing);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user