logout working

master
gitea 2 weeks ago
parent fb96745975
commit c24076a61c

@ -25,7 +25,9 @@ String _apiErrorMessage(RemoteDeckException e) {
} }
class CommunityDecksScreen extends StatefulWidget { class CommunityDecksScreen extends StatefulWidget {
const CommunityDecksScreen({super.key}); const CommunityDecksScreen({super.key, this.showAppBar = true});
final bool showAppBar;
@override @override
State<CommunityDecksScreen> createState() => CommunityDecksScreenState(); State<CommunityDecksScreen> createState() => CommunityDecksScreenState();
@ -178,9 +180,7 @@ class CommunityDecksScreenState extends State<CommunityDecksScreen> {
final user = ApiAuthService.instance.currentUser.value; final user = ApiAuthService.instance.currentUser.value;
if (user == null) { if (user == null) {
return Scaffold( final body = Center(
appBar: AppBar(title: const Text('Community decks')),
body: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
@ -200,13 +200,14 @@ class CommunityDecksScreenState extends State<CommunityDecksScreen> {
], ],
), ),
), ),
),
); );
if (widget.showAppBar) {
return Scaffold(appBar: AppBar(title: const Text('Community decks')), body: body);
}
return body;
} }
return Scaffold( final body = _loading
appBar: AppBar(title: const Text('Community decks')),
body: _loading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _error != null : _error != null
? Center( ? Center(
@ -294,7 +295,10 @@ class CommunityDecksScreenState extends State<CommunityDecksScreen> {
); );
}, },
), ),
),
); );
if (widget.showAppBar) {
return Scaffold(appBar: AppBar(title: const Text('Community decks')), body: body);
}
return body;
} }
} }

@ -8,7 +8,9 @@ import '../data/default_deck.dart';
import '../utils/top_snackbar.dart'; import '../utils/top_snackbar.dart';
class DeckListScreen extends StatefulWidget { class DeckListScreen extends StatefulWidget {
const DeckListScreen({super.key}); const DeckListScreen({super.key, this.showAppBar = true});
final bool showAppBar;
@override @override
State<DeckListScreen> createState() => DeckListScreenState(); State<DeckListScreen> createState() => DeckListScreenState();
@ -437,94 +439,8 @@ class DeckListScreenState extends State<DeckListScreen> {
_loadDecks(); _loadDecks();
} }
@override Widget _buildBody(BuildContext context) {
Widget build(BuildContext context) { return _decks.isEmpty
return Scaffold(
appBar: AppBar(
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: [
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(),
),
],
],
);
},
),
],
),
body: _decks.isEmpty
? Center( ? Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -727,7 +643,95 @@ class DeckListScreenState extends State<DeckListScreen> {
); );
}, },
), ),
);
}
@override
Widget build(BuildContext context) {
final body = _buildBody(context);
if (!widget.showAppBar) {
return body;
}
return Scaffold(
appBar: AppBar(
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: [
ValueListenableBuilder<ApiUser?>(
valueListenable: ApiAuthService.instance.currentUser,
builder: (context, user, _) {
if (user == null) {
return FilledButton.tonal(
onPressed: () => Navigator.of(context).pushNamed(Routes.login),
child: const Text('Log in'),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.sync),
tooltip: 'Sync from server',
onPressed: () => _loadDecks(),
),
Padding(
padding: const EdgeInsets.only(right: 4),
child: PopupMenuButton<String>(
offset: const Offset(0, 40),
tooltip: 'Account',
child: UserAvatar(user: user),
itemBuilder: (context) => [
const PopupMenuItem<String>(
value: 'logout',
child: Row(
children: [
Icon(Icons.logout),
SizedBox(width: 12),
Text('Log out'),
],
),
),
],
onSelected: (value) async {
if (value != 'logout') return;
final navigator = Navigator.of(context);
await ApiAuthService.instance.logout();
navigator.pushNamedAndRemoveUntil(
Routes.login,
(route) => false,
);
},
),
),
],
);
},
), ),
],
),
body: body,
); );
} }
} }
@ -767,6 +771,49 @@ class _StatChip extends StatelessWidget {
} }
} }
/// Shows profile image from [ApiUser.avatarUrl] when available, otherwise initials.
/// Public so HomeScreen can use the same avatar in the shared app bar.
class UserAvatar extends StatelessWidget {
const UserAvatar({super.key, required this.user});
final ApiUser user;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final initials = DeckListScreenState._userInitials(user);
final initialsWidget = Text(
initials,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
fontSize: 16,
),
);
final url = user.avatarUrl?.trim();
if (url == null || url.isEmpty) {
return CircleAvatar(
radius: 18,
backgroundColor: theme.colorScheme.primaryContainer,
child: initialsWidget,
);
}
return CircleAvatar(
radius: 18,
backgroundColor: theme.colorScheme.primaryContainer,
child: ClipOval(
child: Image.network(
url,
width: 36,
height: 36,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => initialsWidget,
),
),
);
}
}
class _MockDataDialog extends StatefulWidget { class _MockDataDialog extends StatefulWidget {
@override @override
State<_MockDataDialog> createState() => _MockDataDialogState(); State<_MockDataDialog> createState() => _MockDataDialogState();

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'deck_list_screen.dart'; import '../routes.dart';
import '../services/api_auth_service.dart';
import 'community_decks_screen.dart'; import 'community_decks_screen.dart';
import 'deck_list_screen.dart';
/// Shell with tab navigation: My Decks and Community. /// Shell with tab navigation: My Decks and Community.
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
@ -29,11 +31,89 @@ class _HomeScreenState extends State<HomeScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(
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: [
ValueListenableBuilder<ApiUser?>(
valueListenable: ApiAuthService.instance.currentUser,
builder: (context, user, _) {
if (user == null) {
return FilledButton.tonal(
onPressed: () => Navigator.of(context).pushNamed(Routes.login),
child: const Text('Log in'),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.sync),
tooltip: 'Sync from server',
onPressed: () => _deckListKey.currentState?.refresh(),
),
Padding(
padding: const EdgeInsets.only(right: 4),
child: PopupMenuButton<String>(
offset: const Offset(0, 40),
tooltip: 'Account',
child: UserAvatar(user: user),
itemBuilder: (context) => [
const PopupMenuItem<String>(
value: 'logout',
child: Row(
children: [
Icon(Icons.logout),
SizedBox(width: 12),
Text('Log out'),
],
),
),
],
onSelected: (value) async {
if (value != 'logout') return;
final navigator = Navigator.of(context);
await ApiAuthService.instance.logout();
navigator.pushNamedAndRemoveUntil(
Routes.login,
(route) => false,
);
},
),
),
],
);
},
),
],
),
body: IndexedStack( body: IndexedStack(
index: _currentIndex == 2 ? 1 : 0, index: _currentIndex == 2 ? 1 : 0,
children: [ children: [
DeckListScreen(key: _deckListKey), DeckListScreen(key: _deckListKey, showAppBar: false),
CommunityDecksScreen(key: _communityKey), CommunityDecksScreen(key: _communityKey, showAppBar: false),
], ],
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(

@ -12,18 +12,52 @@ class ApiUser {
final String id; final String id;
final String? email; final String? email;
final String? displayName; final String? displayName;
final String? avatarUrl;
const ApiUser({required this.id, this.email, this.displayName}); const ApiUser({
required this.id,
this.email,
this.displayName,
this.avatarUrl,
});
ApiUser copyWith({
String? id,
String? email,
String? displayName,
String? avatarUrl,
}) {
return ApiUser(
id: id ?? this.id,
email: email ?? this.email,
displayName: displayName ?? this.displayName,
avatarUrl: avatarUrl ?? this.avatarUrl,
);
}
factory ApiUser.fromJson(Map<String, dynamic> json) { factory ApiUser.fromJson(Map<String, dynamic> json) {
final metadata = json['user_metadata'] as Map<String, dynamic>?; final metadata = json['user_metadata'] as Map<String, dynamic>?;
final avatar = json['avatar_url'] as String?;
final avatarTrimmed = avatar?.trim();
return ApiUser( return ApiUser(
id: json['id'] as String? ?? '', id: json['id'] as String? ?? '',
email: json['email'] as String?, email: json['email'] as String?,
displayName: json['display_name'] as String? ?? displayName: json['display_name'] as String? ??
metadata?['display_name'] as String?, metadata?['display_name'] as String?,
avatarUrl: (avatarTrimmed != null && avatarTrimmed.isNotEmpty)
? avatarTrimmed
: null,
); );
} }
Map<String, dynamic> toJson() {
return {
'id': id,
if (email != null) 'email': email,
if (displayName != null) 'display_name': displayName,
if (avatarUrl != null) 'avatar_url': avatarUrl,
};
}
} }
/// Auth service that uses the backend REST API (login, token storage). /// Auth service that uses the backend REST API (login, token storage).
@ -58,6 +92,9 @@ class ApiAuthService {
final ok = await _fetchSession(); final ok = await _fetchSession();
if (!ok) await logout(); if (!ok) await logout();
} }
if (_token != null && currentUser.value != null) {
await _refreshProfile();
}
} }
Future<bool> _fetchSession() async { Future<bool> _fetchSession() async {
@ -81,6 +118,37 @@ class ApiAuthService {
} }
} }
/// GET /api/auth/profile fetches profile (display_name, email, avatar_url) and updates currentUser.
Future<void> _refreshProfile() async {
if (_token == null) return;
try {
final uri = Uri.parse('$apiBaseUrl/api/auth/profile');
final res = await http.get(
uri,
headers: {'Authorization': 'Bearer $_token'},
);
if (res.statusCode != 200) return;
final data = jsonDecode(res.body) as Map<String, dynamic>;
final id = data['id'] as String? ?? currentUser.value?.id ?? '';
final displayName = data['display_name'] as String?;
final email = data['email'] as String?;
final avatarUrl = data['avatar_url'] as String?;
final trimmedAvatar = avatarUrl?.trim();
final newUser = (currentUser.value ?? ApiUser(id: id)).copyWith(
displayName: displayName ?? currentUser.value?.displayName,
email: email ?? currentUser.value?.email,
avatarUrl: (trimmedAvatar != null && trimmedAvatar.isNotEmpty)
? trimmedAvatar
: currentUser.value?.avatarUrl,
);
currentUser.value = newUser;
final prefs = await _prefs;
await prefs.setString(_userKey, jsonEncode(newUser.toJson()));
} catch (_) {
// Keep existing user if profile fetch fails
}
}
/// Returns the current Bearer token for API requests, or null if not logged in. /// Returns the current Bearer token for API requests, or null if not logged in.
String? get token => _token; String? get token => _token;
@ -126,6 +194,7 @@ class ApiAuthService {
final prefs = await _prefs; final prefs = await _prefs;
await prefs.setString(_tokenKey, _token!); await prefs.setString(_tokenKey, _token!);
await prefs.setString(_userKey, jsonEncode(userJson)); await prefs.setString(_userKey, jsonEncode(userJson));
await _refreshProfile();
return null; return null;
} catch (e) { } catch (e) {
return connectionErrorMessage(e); return connectionErrorMessage(e);
@ -170,17 +239,19 @@ class ApiAuthService {
final prefs = await _prefs; final prefs = await _prefs;
await prefs.setString(_tokenKey, _token!); await prefs.setString(_tokenKey, _token!);
await prefs.setString(_userKey, jsonEncode(userJson)); await prefs.setString(_userKey, jsonEncode(userJson));
await _refreshProfile();
return null; return null;
} catch (e) { } catch (e) {
return connectionErrorMessage(e); return connectionErrorMessage(e);
} }
} }
/// Clears session (token + user) and persists. Call this to log out.
Future<void> logout() async { Future<void> logout() async {
_token = null;
currentUser.value = null;
final prefs = await _prefs; final prefs = await _prefs;
await prefs.remove(_tokenKey); await prefs.remove(_tokenKey);
await prefs.remove(_userKey); await prefs.remove(_userKey);
_token = null;
currentUser.value = null;
} }
} }

Loading…
Cancel
Save

Powered by TurnKey Linux.