You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
decky/lib/screens/community_decks_screen.dart

313 lines
12 KiB

import 'dart:convert';
import 'package:flutter/material.dart';
import '../routes.dart';
import '../services/api_auth_service.dart';
import '../services/deck_storage.dart';
import '../services/remote_deck_service.dart';
import '../utils/connection_error.dart';
import '../utils/top_snackbar.dart';
String _apiErrorMessage(RemoteDeckException e) {
if (e.statusCode == 401 || e.statusCode == 403) {
return 'Session expired. Please log in again.';
}
if (e.statusCode >= 500) {
return 'Connection to server has broken. Check API URL and network.';
}
try {
final map = jsonDecode(e.body) as Map<String, dynamic>?;
final msg = map?['error'] as String? ?? map?['message'] as String?;
if (msg != null && msg.isNotEmpty) return msg;
} catch (_) {}
return 'Failed to load community decks.';
}
class CommunityDecksScreen extends StatefulWidget {
const CommunityDecksScreen({super.key, this.showAppBar = true});
final bool showAppBar;
@override
State<CommunityDecksScreen> createState() => CommunityDecksScreenState();
}
class CommunityDecksScreenState extends State<CommunityDecksScreen> {
List<RemoteDeckListItem> _decks = [];
bool _loading = true;
String? _error;
final DeckStorage _storage = DeckStorage();
final RemoteDeckService _remote = RemoteDeckService.instance;
@override
void initState() {
super.initState();
_load();
}
void refresh() {
_load();
}
/// Published deck ids (community list id) that we already have in My decks:
/// - copied_from_deck_id when we added from Community, or
/// - server_deck_id when we own/synced that deck (e.g. our own published deck).
Future<Set<String>> _localPublishedDeckIdsWeHave() async {
await _storage.initialize();
final allDecks = await _storage.getAllDecks();
final ids = <String>{};
for (final deck in allDecks) {
final sync = _storage.getDeckSyncMetadataSync(deck.id);
if (sync == null) continue;
final serverId = sync['server_deck_id']?.toString().trim();
if (serverId != null && serverId.isNotEmpty) ids.add(serverId);
final fromId = sync['copied_from_deck_id']?.toString().trim();
if (fromId != null && fromId.isNotEmpty) ids.add(fromId);
}
return ids;
}
Future<void> _load() async {
setState(() {
_loading = true;
_error = null;
});
try {
await _storage.initialize();
if (!mounted) return;
final list = await _remote.getPublishedDecks();
if (!mounted) return;
final localCopyIds = await _localPublishedDeckIdsWeHave();
if (!mounted) return;
final merged = list.map((d) {
final publishedId = d.id.toString().trim();
final inMyDecks = publishedId.isNotEmpty && localCopyIds.contains(publishedId);
return RemoteDeckListItem(
id: d.id,
title: d.title,
description: d.description,
questionCount: d.questionCount,
userHasThis: inMyDecks,
needsUpdate: d.needsUpdate,
copiedFromDeckId: d.copiedFromDeckId,
ownerDisplayName: d.ownerDisplayName,
averageRating: d.averageRating,
ratingCount: d.ratingCount,
);
}).toList();
if (mounted) {
setState(() {
_decks = merged;
_loading = false;
_error = null;
});
}
} on RemoteDeckException catch (e) {
if (mounted) {
setState(() {
_loading = false;
_error = _apiErrorMessage(e);
});
}
} catch (e) {
if (mounted) {
setState(() {
_loading = false;
_error = connectionErrorMessage(e);
});
}
}
}
Future<void> _addToMyDecks(RemoteDeckListItem item) async {
if (ApiAuthService.instance.currentUser.value == null) {
Navigator.pushNamed(context, Routes.login).then((_) => _load());
return;
}
try {
final newId = await _remote.copyDeck(item.id);
final deck = await _remote.getDeck(newId);
final syncMetadata = {
'server_deck_id': newId,
'owner_id': ApiAuthService.instance.currentUser.value!.id,
'copied_from_deck_id': item.id,
'copied_from_version': null,
'published': false,
'needs_update': false,
};
_storage.saveDeckSync(deck, syncMetadata: syncMetadata);
if (mounted) {
showTopSnackBar(
context,
message: 'Added "${deck.title}" to your decks',
backgroundColor: Colors.green,
);
_load();
}
} on RemoteDeckException catch (e) {
if (mounted) {
showTopSnackBar(
context,
message: e.statusCode == 401
? 'Session expired. Please log in again.'
: 'Could not add deck.',
backgroundColor: Colors.red,
);
}
} catch (e) {
if (mounted) {
showTopSnackBar(
context,
message: connectionErrorMessage(e),
backgroundColor: Colors.red,
);
}
}
}
@override
Widget build(BuildContext context) {
final user = ApiAuthService.instance.currentUser.value;
final body = _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_error!, textAlign: TextAlign.center),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FilledButton(
onPressed: _load,
child: const Text('Retry'),
),
if (_error!.contains('Session expired')) ...[
const SizedBox(width: 12),
FilledButton.tonal(
onPressed: () async {
await ApiAuthService.instance.logout();
if (!mounted) return;
Navigator.pushNamed(context, Routes.login)
.then((_) => _load());
},
child: const Text('Log in again'),
),
],
],
),
],
),
),
)
: _decks.isEmpty
? Center(
child: Text(
'No published decks yet.',
style: Theme.of(context).textTheme.bodyLarge,
),
)
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _decks.length,
itemBuilder: (context, index) {
final d = _decks[index];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
title: Text(d.title),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (d.description.isNotEmpty)
Text(
d.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
Row(
children: [
Icon(
Icons.person_outline,
size: 14,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
d.ownerDisplayName != null && d.ownerDisplayName!.isNotEmpty
? d.ownerDisplayName!
: 'Unknown',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 2),
Row(
children: [
Text(
'${d.questionCount} questions',
style: Theme.of(context).textTheme.bodySmall,
),
if (d.averageRating != null) ...[
const SizedBox(width: 12),
Icon(
Icons.star,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(width: 2),
Text(
d.ratingCount != null && d.ratingCount! > 0
? '${d.averageRating!.toStringAsFixed(1)} (${d.ratingCount})'
: d.averageRating!.toStringAsFixed(1),
style: Theme.of(context).textTheme.bodySmall,
),
],
],
),
],
),
isThreeLine: true,
trailing: d.userHasThis
? const Chip(
label: Text('In my decks'),
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
)
: user == null
? FilledButton.tonal(
onPressed: () => Navigator.pushNamed(
context,
Routes.login,
).then((_) => _load()),
child: const Text('Log in to add'),
)
: FilledButton.tonal(
onPressed: () => _addToMyDecks(d),
child: const Text('Add'),
),
),
);
},
),
);
if (widget.showAppBar) {
return Scaffold(appBar: AppBar(title: const Text('Community decks')), body: body);
}
return body;
}
}

Powered by TurnKey Linux.