clud features
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
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});
|
||||
|
||||
@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 {
|
||||
final user = ApiAuthService.instance.currentUser.value;
|
||||
if (user == null) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_decks = [];
|
||||
_error = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
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;
|
||||
|
||||
if (user == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Community decks')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Log in to browse and add community decks.',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pushNamed(context, Routes.login)
|
||||
.then((_) => _load()),
|
||||
child: const Text('Log in'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Community decks')),
|
||||
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: 4),
|
||||
Text(
|
||||
'${d.questionCount} questions'
|
||||
'${d.ownerDisplayName != null ? ' · ${d.ownerDisplayName}' : ''}',
|
||||
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,
|
||||
),
|
||||
)
|
||||
: FilledButton.tonal(
|
||||
onPressed: () => _addToMyDecks(d),
|
||||
child: const Text('Add'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -203,85 +203,95 @@ class _DeckEditScreenState extends State<DeckEditScreen> {
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
body: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Deck Title
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Deck Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// Deck Title
|
||||
TextField(
|
||||
controller: _titleController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Deck Title',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Deck Description
|
||||
TextField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Questions',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Deck Description
|
||||
TextField(
|
||||
controller: _descriptionController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Description (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
maxLines: 3,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Questions Section
|
||||
Text(
|
||||
'Questions',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Questions List
|
||||
...List.generate(_questionEditors.length, (index) {
|
||||
return QuestionEditorCard(
|
||||
key: ValueKey('question_$index'),
|
||||
editor: _questionEditors[index],
|
||||
questionNumber: index + 1,
|
||||
onDelete: () => _removeQuestion(index),
|
||||
onUnflag: null,
|
||||
onChanged: () => setState(() {}),
|
||||
requestFocusOnPrompt: _focusNewQuestionIndex == index,
|
||||
);
|
||||
}),
|
||||
|
||||
if (_questionEditors.isEmpty)
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.quiz_outlined,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No questions yet',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap + in the app bar to add a question',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_questionEditors.isEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.quiz_outlined,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No questions yet',
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap + in the app bar to add a question',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: _questionEditors.length,
|
||||
itemBuilder: (context, index) {
|
||||
return RepaintBoundary(
|
||||
child: QuestionEditorCard(
|
||||
key: ValueKey('question_$index'),
|
||||
editor: _questionEditors[index],
|
||||
questionNumber: index + 1,
|
||||
onDelete: () => _removeQuestion(index),
|
||||
onUnflag: null,
|
||||
onChanged: () => setState(() {}),
|
||||
requestFocusOnPrompt: _focusNewQuestionIndex == index,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:practice_engine/practice_engine.dart';
|
||||
import '../routes.dart';
|
||||
import '../services/api_auth_service.dart';
|
||||
import '../services/deck_storage.dart';
|
||||
import '../services/remote_deck_service.dart';
|
||||
import '../data/default_deck.dart';
|
||||
import '../utils/top_snackbar.dart';
|
||||
|
||||
@@ -9,10 +11,10 @@ class DeckListScreen extends StatefulWidget {
|
||||
const DeckListScreen({super.key});
|
||||
|
||||
@override
|
||||
State<DeckListScreen> createState() => _DeckListScreenState();
|
||||
State<DeckListScreen> createState() => DeckListScreenState();
|
||||
}
|
||||
|
||||
class _DeckListScreenState extends State<DeckListScreen> {
|
||||
class DeckListScreenState extends State<DeckListScreen> {
|
||||
final DeckStorage _deckStorage = DeckStorage();
|
||||
List<Deck> _decks = [];
|
||||
|
||||
@@ -20,21 +22,75 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadDecks();
|
||||
ApiAuthService.instance.currentUser.addListener(_onAuthChanged);
|
||||
}
|
||||
|
||||
void _onAuthChanged() {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
ApiAuthService.instance.currentUser.removeListener(_onAuthChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Call from parent (e.g. when switching to My Decks tab) to refresh the list.
|
||||
void refresh() {
|
||||
_loadDecks();
|
||||
}
|
||||
|
||||
void _loadDecks() {
|
||||
// Use sync version for immediate UI update, will work once storage is initialized
|
||||
setState(() {
|
||||
_decks = _deckStorage.getAllDecksSync();
|
||||
});
|
||||
// Also trigger async load to ensure we have latest data
|
||||
_deckStorage.getAllDecks().then((decks) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_decks = decks;
|
||||
});
|
||||
_loadDecksAsync();
|
||||
}
|
||||
|
||||
/// Server deck ids we already have locally (by deck.id or sync.server_deck_id).
|
||||
Set<String> _localServerDeckIds(List<Deck> fromStorage) {
|
||||
final ids = <String>{};
|
||||
for (final deck in fromStorage) {
|
||||
ids.add(deck.id);
|
||||
final sync = _deckStorage.getDeckSyncMetadataSync(deck.id);
|
||||
final serverId = sync?['server_deck_id']?.toString().trim();
|
||||
if (serverId != null && serverId.isNotEmpty) ids.add(serverId);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/// Load from local storage, then if logged in fetch "my decks" from server and add any missing ones locally.
|
||||
Future<void> _loadDecksAsync() async {
|
||||
await _deckStorage.initialize();
|
||||
if (!mounted) return;
|
||||
var fromStorage = await _deckStorage.getAllDecks();
|
||||
if (!mounted) return;
|
||||
setState(() => _decks = fromStorage);
|
||||
|
||||
final user = ApiAuthService.instance.currentUser.value;
|
||||
if (user == null) return;
|
||||
|
||||
try {
|
||||
final myDecks = await RemoteDeckService.instance.getMyDecks();
|
||||
final haveIds = _localServerDeckIds(fromStorage);
|
||||
for (final item in myDecks) {
|
||||
final serverId = item.id.toString().trim();
|
||||
if (serverId.isEmpty || haveIds.contains(serverId)) continue;
|
||||
final deck = await RemoteDeckService.instance.getDeck(serverId);
|
||||
final syncMetadata = {
|
||||
'server_deck_id': serverId,
|
||||
'owner_id': user.id,
|
||||
'copied_from_deck_id': item.copiedFromDeckId,
|
||||
'published': false,
|
||||
'needs_update': item.needsUpdate,
|
||||
};
|
||||
_deckStorage.saveDeckSync(deck, syncMetadata: syncMetadata);
|
||||
}
|
||||
});
|
||||
final updated = await _deckStorage.getAllDecks();
|
||||
if (mounted) setState(() => _decks = updated);
|
||||
} catch (_) {
|
||||
// Keep showing local decks if server unreachable
|
||||
}
|
||||
}
|
||||
|
||||
void _openDeck(Deck deck) {
|
||||
@@ -70,19 +126,35 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
_deckStorage.deleteDeckSync(deck.id);
|
||||
_loadDecks();
|
||||
if (mounted) {
|
||||
showTopSnackBar(
|
||||
context,
|
||||
message: '${deck.title} deleted',
|
||||
backgroundColor: Colors.green,
|
||||
);
|
||||
}
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
_deckStorage.deleteDeckSync(deck.id);
|
||||
_loadDecks();
|
||||
if (mounted) {
|
||||
showTopSnackBar(
|
||||
context,
|
||||
message: '${deck.title} deleted',
|
||||
backgroundColor: Colors.green,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static String _userInitials(ApiUser user) {
|
||||
final name = user.displayName?.trim();
|
||||
if (name != null && name.isNotEmpty) {
|
||||
final parts = name.split(RegExp(r'\s+'));
|
||||
if (parts.length >= 2) {
|
||||
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
|
||||
}
|
||||
return name.substring(0, name.length.clamp(0, 2)).toUpperCase();
|
||||
}
|
||||
final email = user.email?.trim();
|
||||
if (email != null && email.isNotEmpty) {
|
||||
return email[0].toUpperCase();
|
||||
}
|
||||
return '?';
|
||||
}
|
||||
|
||||
void _navigateToImport() {
|
||||
Navigator.pushNamed(context, Routes.deckImport).then((_) {
|
||||
// Reload decks when returning from import
|
||||
@@ -195,7 +267,8 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
void _showAddDeckOptions() {
|
||||
/// Called from app bar or bottom nav (HomeScreen). Public so HomeScreen can trigger add.
|
||||
void showAddDeckOptions() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
@@ -368,12 +441,86 @@ class _DeckListScreenState extends State<DeckListScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('omotomo'),
|
||||
title: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
'android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png',
|
||||
height: 32,
|
||||
width: 32,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, __, ___) => Icon(
|
||||
Icons.auto_stories,
|
||||
size: 28,
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'omotomo',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: 'Add Deck',
|
||||
onPressed: _showAddDeckOptions,
|
||||
ValueListenableBuilder<ApiUser?>(
|
||||
valueListenable: ApiAuthService.instance.currentUser,
|
||||
builder: (context, user, _) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (user != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: PopupMenuButton<void>(
|
||||
offset: const Offset(0, 40),
|
||||
tooltip: 'Account',
|
||||
child: CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
_userInitials(user),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem<void>(
|
||||
value: null,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.logout),
|
||||
SizedBox(width: 12),
|
||||
Text('Log out'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (_) async {
|
||||
await ApiAuthService.instance.logout();
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||
Routes.login,
|
||||
(route) => false,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.sync),
|
||||
tooltip: 'Sync from server',
|
||||
onPressed: () => _loadDecks(),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'deck_list_screen.dart';
|
||||
import 'community_decks_screen.dart';
|
||||
|
||||
/// Shell with tab navigation: My Decks and Community.
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
State<HomeScreen> createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
int _currentIndex = 0;
|
||||
final GlobalKey<DeckListScreenState> _deckListKey = GlobalKey<DeckListScreenState>();
|
||||
final GlobalKey<CommunityDecksScreenState> _communityKey = GlobalKey<CommunityDecksScreenState>();
|
||||
|
||||
void _onDestinationSelected(int index) {
|
||||
if (index == _currentIndex) return;
|
||||
setState(() => _currentIndex = index);
|
||||
if (index == 0) {
|
||||
_deckListKey.currentState?.refresh();
|
||||
} else if (index == 2) {
|
||||
_communityKey.currentState?.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: IndexedStack(
|
||||
index: _currentIndex == 2 ? 1 : 0,
|
||||
children: [
|
||||
DeckListScreen(key: _deckListKey),
|
||||
CommunityDecksScreen(key: _communityKey),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _deckListKey.currentState?.showAddDeckOptions(),
|
||||
tooltip: 'Add Deck',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
|
||||
bottomNavigationBar: BottomAppBar(
|
||||
notchMargin: 8,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: [
|
||||
_NavItem(
|
||||
icon: Icons.folder,
|
||||
label: 'My Decks',
|
||||
selected: _currentIndex == 0,
|
||||
onTap: () => _onDestinationSelected(0),
|
||||
),
|
||||
const SizedBox(width: 56),
|
||||
_NavItem(
|
||||
icon: Icons.people,
|
||||
label: 'Community',
|
||||
selected: _currentIndex == 2,
|
||||
onTap: () => _onDestinationSelected(2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _NavItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: selected ? Theme.of(context).colorScheme.primary : null),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: selected ? Theme.of(context).colorScheme.primary : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../routes.dart';
|
||||
import '../services/api_auth_service.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _loading = false;
|
||||
String? _errorMessage;
|
||||
bool _isRegister = false;
|
||||
final _displayNameController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_displayNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
setState(() {
|
||||
_errorMessage = null;
|
||||
_loading = true;
|
||||
});
|
||||
|
||||
String? err;
|
||||
if (_isRegister) {
|
||||
err = await ApiAuthService.instance.register(
|
||||
_emailController.text,
|
||||
_passwordController.text,
|
||||
_displayNameController.text,
|
||||
);
|
||||
} else {
|
||||
err = await ApiAuthService.instance.login(
|
||||
_emailController.text,
|
||||
_passwordController.text,
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_errorMessage = err;
|
||||
});
|
||||
|
||||
if (err == null) {
|
||||
Navigator.pushReplacementNamed(context, Routes.deckList);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_isRegister ? 'Create account' : 'Log in'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
if (_isRegister) ...[
|
||||
TextFormField(
|
||||
controller: _displayNameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Display name (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
decoration: InputDecoration(
|
||||
labelText: _isRegister ? 'Email' : 'Email or username',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: _isRegister ? TextInputType.emailAddress : TextInputType.text,
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: (v) {
|
||||
if (v == null || v.trim().isEmpty) {
|
||||
return _isRegister ? 'Enter your email.' : 'Enter your email or username.';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
validator: (v) {
|
||||
if (v == null || v.isEmpty) return 'Enter your password.';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: _loading
|
||||
? null
|
||||
: () {
|
||||
if (_formKey.currentState?.validate() ?? false) {
|
||||
_submit();
|
||||
}
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(_isRegister ? 'Create account' : 'Log in'),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton(
|
||||
onPressed: _loading
|
||||
? null
|
||||
: () {
|
||||
setState(() {
|
||||
_isRegister = !_isRegister;
|
||||
_errorMessage = null;
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
_isRegister
|
||||
? 'Already have an account? Log in'
|
||||
: 'No account? Create one',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user