diff --git a/lib/screens/community_decks_screen.dart b/lib/screens/community_decks_screen.dart index 0e7dccf..b3843fc 100644 --- a/lib/screens/community_decks_screen.dart +++ b/lib/screens/community_decks_screen.dart @@ -25,7 +25,9 @@ String _apiErrorMessage(RemoteDeckException e) { } class CommunityDecksScreen extends StatefulWidget { - const CommunityDecksScreen({super.key}); + const CommunityDecksScreen({super.key, this.showAppBar = true}); + + final bool showAppBar; @override State createState() => CommunityDecksScreenState(); @@ -178,9 +180,7 @@ class CommunityDecksScreenState extends State { final user = ApiAuthService.instance.currentUser.value; if (user == null) { - return Scaffold( - appBar: AppBar(title: const Text('Community decks')), - body: Center( + final body = Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( @@ -200,13 +200,14 @@ class CommunityDecksScreenState extends State { ], ), ), - ), - ); + ); + if (widget.showAppBar) { + return Scaffold(appBar: AppBar(title: const Text('Community decks')), body: body); + } + return body; } - return Scaffold( - appBar: AppBar(title: const Text('Community decks')), - body: _loading + final body = _loading ? const Center(child: CircularProgressIndicator()) : _error != null ? Center( @@ -294,7 +295,10 @@ class CommunityDecksScreenState extends State { ); }, ), - ), - ); + ); + if (widget.showAppBar) { + return Scaffold(appBar: AppBar(title: const Text('Community decks')), body: body); + } + return body; } } diff --git a/lib/screens/deck_list_screen.dart b/lib/screens/deck_list_screen.dart index d11bcdb..5d6e055 100644 --- a/lib/screens/deck_list_screen.dart +++ b/lib/screens/deck_list_screen.dart @@ -8,7 +8,9 @@ import '../data/default_deck.dart'; import '../utils/top_snackbar.dart'; class DeckListScreen extends StatefulWidget { - const DeckListScreen({super.key}); + const DeckListScreen({super.key, this.showAppBar = true}); + + final bool showAppBar; @override State createState() => DeckListScreenState(); @@ -437,94 +439,8 @@ class DeckListScreenState extends State { _loadDecks(); } - @override - Widget build(BuildContext context) { - 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( - 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( - 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( - 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 + Widget _buildBody(BuildContext context) { + return _decks.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -727,7 +643,95 @@ class DeckListScreenState extends State { ); }, ), + ); + } + + @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( + 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( + offset: const Offset(0, 40), + tooltip: 'Account', + child: UserAvatar(user: user), + itemBuilder: (context) => [ + const PopupMenuItem( + 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 { @override State<_MockDataDialog> createState() => _MockDataDialogState(); diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index c8b6178..e50c899 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -1,7 +1,9 @@ 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 'deck_list_screen.dart'; /// Shell with tab navigation: My Decks and Community. class HomeScreen extends StatefulWidget { @@ -29,11 +31,89 @@ class _HomeScreenState extends State { @override Widget build(BuildContext context) { 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( + 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( + offset: const Offset(0, 40), + tooltip: 'Account', + child: UserAvatar(user: user), + itemBuilder: (context) => [ + const PopupMenuItem( + 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( index: _currentIndex == 2 ? 1 : 0, children: [ - DeckListScreen(key: _deckListKey), - CommunityDecksScreen(key: _communityKey), + DeckListScreen(key: _deckListKey, showAppBar: false), + CommunityDecksScreen(key: _communityKey, showAppBar: false), ], ), floatingActionButton: FloatingActionButton( diff --git a/lib/services/api_auth_service.dart b/lib/services/api_auth_service.dart index 7222477..8dc8dcb 100644 --- a/lib/services/api_auth_service.dart +++ b/lib/services/api_auth_service.dart @@ -12,18 +12,52 @@ class ApiUser { final String id; final String? email; 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 json) { final metadata = json['user_metadata'] as Map?; + final avatar = json['avatar_url'] as String?; + final avatarTrimmed = avatar?.trim(); return ApiUser( id: json['id'] as String? ?? '', email: json['email'] as String?, displayName: json['display_name'] as String? ?? metadata?['display_name'] as String?, + avatarUrl: (avatarTrimmed != null && avatarTrimmed.isNotEmpty) + ? avatarTrimmed + : null, ); } + + Map 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). @@ -58,6 +92,9 @@ class ApiAuthService { final ok = await _fetchSession(); if (!ok) await logout(); } + if (_token != null && currentUser.value != null) { + await _refreshProfile(); + } } Future _fetchSession() async { @@ -81,6 +118,37 @@ class ApiAuthService { } } + /// GET /api/auth/profile — fetches profile (display_name, email, avatar_url) and updates currentUser. + Future _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; + 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. String? get token => _token; @@ -126,6 +194,7 @@ class ApiAuthService { final prefs = await _prefs; await prefs.setString(_tokenKey, _token!); await prefs.setString(_userKey, jsonEncode(userJson)); + await _refreshProfile(); return null; } catch (e) { return connectionErrorMessage(e); @@ -170,17 +239,19 @@ class ApiAuthService { final prefs = await _prefs; await prefs.setString(_tokenKey, _token!); await prefs.setString(_userKey, jsonEncode(userJson)); + await _refreshProfile(); return null; } catch (e) { return connectionErrorMessage(e); } } + /// Clears session (token + user) and persists. Call this to log out. Future logout() async { - _token = null; - currentUser.value = null; final prefs = await _prefs; await prefs.remove(_tokenKey); await prefs.remove(_userKey); + _token = null; + currentUser.value = null; } }