clud features

This commit is contained in:
gitea
2026-02-15 17:57:17 +01:00
parent b67e562423
commit fb96745975
15 changed files with 1495 additions and 119 deletions
+300
View File
@@ -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'),
),
),
);
},
),
),
);
}
}
+83 -73
View File
@@ -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,
),
);
},
),
),
],
),
);
}
+173 -26
View File
@@ -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(),
),
],
],
);
},
),
],
),
+107
View File
@@ -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,
),
),
],
),
),
);
}
}
+167
View File
@@ -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',
),
),
],
),
),
),
),
);
}
}