From 947e119dba60e043e77862ad25103afc55d4cce6 Mon Sep 17 00:00:00 2001 From: 230404 <230404@epvc.pt> Date: Mon, 8 Jun 2026 14:54:04 +0100 Subject: [PATCH] tentar aresolver a home --- .github/copilot-instructions.md | 51 +++++++ lib/controllers/active_team.dart | 58 +++++--- lib/controllers/team_controller.dart | 33 ++++- lib/pages/home.dart | 205 +++++++++++++++------------ lib/pages/status_page.dart | 33 +++++ 5 files changed, 264 insertions(+), 116 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..fd8c46d --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,51 @@ +## PlayMaker — guidance for AI coding assistants + +This file gives focused, actionable knowledge to quickly make safe edits in the PlayMaker Flutter app. + +Key facts (big picture) +- App: Flutter (mobile + desktop) using Supabase for backend and SharedPreferences for local persistence. +- Data ownership: teams are per-user (teams.user_id). Team records include fields like `id`, `name`, `image_url`, `wins`, `losses`, `draws`, `is_favorite`. +- Global selection: the app exposes a single global active team via `lib/controllers/active_team.dart` — a `ValueNotifier` named `globalActiveTeam`. Use `loadGlobalTeam()` and `saveGlobalTeam()` to read/update both local (SharedPreferences) and Supabase (`profiles.selected_team_id`). + +Where to look (important files) +- `lib/controllers/active_team.dart` — central logic: load/save active team, prefers favorite team after our recent change. +- `lib/controllers/team_controller.dart` — creates teams, toggles favorites and exposes `teamsStream` for realtime listing. +- `lib/pages/home.dart` — main UI that shows the selected team, reads/writes last_team_* prefs and uses `TeamController` streams. +- `lib/pages/status_page.dart` — shows detailed stats and accepts initialTeam* props; will use the same persistence keys as `home.dart` as a fallback. +- `lib/widgets/*.dart` — UI building blocks (game, placar, team widgets) that assume team names/logos are strings and use `globalActiveTeam`/selectedTeam state. + +Project-specific conventions & patterns +- Single active team: stored locally under prefs keys `last_team_id`, `last_team_name`, `last_team_logo`, `last_team_wins`, `last_team_losses`, `last_team_draws`. +- Realtime lists: controllers expose Streams from Supabase (e.g., `teamsStream`) and UI uses `StreamBuilder`/`snapshot.data` expecting List>. +- Favoriting: `teams.is_favorite` is a boolean; code expects at most one favorite per user. When marking favorite, clear other favorites for that user and set the favorite as the global active team (we updated `team_controller.toggleFavorite` to do this). +- Global state: prefer using `globalActiveTeam` instead of ad-hoc SharedPreferences reads when changing the current team — it notifies Home and Status pages automatically. +- Naming: Portuguese UI strings are used throughout (e.g., "Selecionar Equipa"), so preserve that when editing visible text. + +Examples of common edits +- Changing how the active team is chosen: edit `loadGlobalTeam()` in `lib/controllers/active_team.dart`. Example behaviour implemented: prefer `teams.is_favorite == true`, then fallback to `profiles.selected_team_id`, then local prefs. +- Adding a field to teams: update Supabase schema, then adapt `TeamController.getTeamsWithStats()` and UI widgets under `lib/widgets/` to read the new field. +- Making UI reactive to team changes: call `saveGlobalTeam(ActiveTeam(...))` to update memory, Supabase profile and notify UI. + +Developer workflows (how to build/test/debug) +- Install dependencies: `flutter pub get`. +- Run on iOS simulator: `flutter run -d ios` (macOS only). Android: `flutter run -d android`. +- Quick analyzer: `flutter analyze` or rely on the project's editor diagnostics. +- Running tests: There is a `test/widget_test.dart` — run `flutter test` to execute. +- When debugging Supabase flows locally, ensure `google-services.json` (Android) and iOS config files are present under `android/app/` and `ios/` as appropriate. + +Safe-edit checklist for AI agents +- Prefer small, focused patches. Keep existing structure and naming conventions (Portuguese strings, `prefs` keys and Supabase table names). +- When touching team selection: update both local prefs and Supabase `profiles.selected_team_id` (use `saveGlobalTeam`). +- When changing persistence keys, update both `home.dart` and `status_page.dart` and `active_team.dart`. +- Avoid altering app-wide themes or widget contracts unless necessary; many widgets rely on team names/logos being non-null strings. +- If adding Supabase queries, filter by `user_id` unless deliberately global. + +If you can't find something +- Search for the pref keys `last_team_id` / `last_team_name` / `last_team_logo` to locate all usages. +- Look for `globalActiveTeam` to see places that react to the active team. + +Contact / next steps +- After applying changes that affect team selection flow, run `flutter analyze` and (if possible) `flutter test`. +- Ask for confirmation before changing user-visible Portuguese copy or database schema. + +— end of guidance — diff --git a/lib/controllers/active_team.dart b/lib/controllers/active_team.dart index ce6008a..5e488b4 100644 --- a/lib/controllers/active_team.dart +++ b/lib/controllers/active_team.dart @@ -46,32 +46,48 @@ Future loadGlobalTeam() async { if (userId == null) return; try { + // 1) Prefer an explicit team selection stored on the user's profile (if any) + Map? teamData; final profile = await supabase.from('profiles').select('selected_team_id').eq('id', userId).maybeSingle(); if (profile != null && profile['selected_team_id'] != null) { final dbTeamId = profile['selected_team_id'].toString(); - final teamData = await supabase.from('teams').select().eq('id', dbTeamId).maybeSingle(); + final dbTeam = await supabase.from('teams').select().eq('id', dbTeamId).maybeSingle(); + if (dbTeam != null) teamData = Map.from(dbTeam); + } - if (teamData != null) { - final newTeam = ActiveTeam( - id: teamData['id'].toString(), - name: teamData['name'] ?? 'Desconhecido', - logo: teamData['image_url'], - wins: int.tryParse(teamData['wins']?.toString() ?? '0') ?? 0, - losses: int.tryParse(teamData['losses']?.toString() ?? '0') ?? 0, - draws: int.tryParse(teamData['draws']?.toString() ?? '0') ?? 0, - ); - globalActiveTeam.value = newTeam; - - // Atualiza a memória do telemóvel para a próxima vez ser rápido - await prefs.setString('last_team_id', newTeam.id); - await prefs.setString('last_team_name', newTeam.name); - if (newTeam.logo != null && newTeam.logo!.isNotEmpty) { - await prefs.setString('last_team_logo', newTeam.logo!); - } - await prefs.setInt('last_team_wins', newTeam.wins); - await prefs.setInt('last_team_losses', newTeam.losses); - await prefs.setInt('last_team_draws', newTeam.draws); + // 2) If the user has no explicit profile selection, fall back to any team + // marked as favorite for that user (acts as a default) + if (teamData == null) { + final favTeam = await supabase + .from('teams') + .select() + .eq('user_id', userId) + .eq('is_favorite', true) + .maybeSingle(); + if (favTeam != null) teamData = Map.from(favTeam); + } + + // If we found a team (favorite or profile selection), set it as active and persist locally + if (teamData != null) { + final newTeam = ActiveTeam( + id: teamData['id'].toString(), + name: teamData['name'] ?? 'Desconhecido', + logo: teamData['image_url'], + wins: int.tryParse(teamData['wins']?.toString() ?? '0') ?? 0, + losses: int.tryParse(teamData['losses']?.toString() ?? '0') ?? 0, + draws: int.tryParse(teamData['draws']?.toString() ?? '0') ?? 0, + ); + globalActiveTeam.value = newTeam; + + // Atualiza a memória do telemóvel para a próxima vez ser rápido + await prefs.setString('last_team_id', newTeam.id); + await prefs.setString('last_team_name', newTeam.name); + if (newTeam.logo != null && newTeam.logo!.isNotEmpty) { + await prefs.setString('last_team_logo', newTeam.logo!); } + await prefs.setInt('last_team_wins', newTeam.wins); + await prefs.setInt('last_team_losses', newTeam.losses); + await prefs.setInt('last_team_draws', newTeam.draws); } } catch (e) { debugPrint("Erro ao carregar equipa do Supabase: $e"); diff --git a/lib/controllers/team_controller.dart b/lib/controllers/team_controller.dart index 4d993d2..c75bb94 100644 --- a/lib/controllers/team_controller.dart +++ b/lib/controllers/team_controller.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:playmaker/controllers/active_team.dart'; class TeamController { final _supabase = Supabase.instance.client; @@ -65,10 +66,34 @@ class TeamController { // 4. FAVORITAR Future toggleFavorite(String teamId, bool currentStatus) async { try { - await _supabase - .from('teams') - .update({'is_favorite': !currentStatus}) - .eq('id', teamId); + final userId = _supabase.auth.currentUser?.id; + if (userId == null) return; + + // If we're marking this team as favorite, clear other favorites for this user + if (!currentStatus) { + await _supabase.from('teams').update({'is_favorite': false}).eq('user_id', userId); + } + + // Toggle the chosen team's favorite flag + await _supabase.from('teams').update({'is_favorite': !currentStatus}).eq('id', teamId); + + // If it became favorite, load its data and set global active team + if (!currentStatus) { + final teamData = await _supabase.from('teams').select().eq('id', teamId).maybeSingle(); + if (teamData != null) { + final newTeam = ActiveTeam( + id: teamData['id'].toString(), + name: teamData['name'] ?? 'Desconhecido', + logo: teamData['image_url'], + wins: int.tryParse(teamData['wins']?.toString() ?? '0') ?? 0, + losses: int.tryParse(teamData['losses']?.toString() ?? '0') ?? 0, + draws: int.tryParse(teamData['draws']?.toString() ?? '0') ?? 0, + ); + + // Update global active team so UI reflects the favorite immediately + await saveGlobalTeam(newTeam); + } + } } catch (e) { print("❌ Erro ao favoritar: $e"); } diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 4abed75..29cd71f 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -219,7 +219,8 @@ class _HomeScreenState extends State { padding: EdgeInsets.all(10.0 * context.sf), child: InkWell( borderRadius: BorderRadius.circular(100), - onTap: () async { + onTap: () async { + debugPrint('Home: settings button tapped'); await Navigator.push( context, MaterialPageRoute( @@ -290,98 +291,120 @@ class _HomeScreenState extends State { } void _showTeamSelector(BuildContext context) { - showModalBottomSheet( - context: context, - backgroundColor: Theme.of(context).colorScheme.surface, - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.vertical(top: Radius.circular(20 * context.sf)), - ), - builder: (context) { - return StreamBuilder>>( - stream: _teamController.teamsStream, - builder: (context, snapshot) { - if (!snapshot.hasData && - snapshot.connectionState == ConnectionState.waiting) { - return const SizedBox( - height: 200, - child: Center(child: CircularProgressIndicator())); - } - if (!snapshot.hasData || snapshot.data!.isEmpty) { - return SizedBox( - height: 200 * context.sf, - child: Center( - child: Text("Nenhuma equipa criada.", - style: TextStyle( - color: - Theme.of(context).colorScheme.onSurface))), - ); - } + debugPrint('showTeamSelector called'); + final isWide = MediaQuery.of(context).size.width >= 900; - final teams = snapshot.data!; - return ListView.builder( - shrinkWrap: true, - itemCount: teams.length, - itemBuilder: (context, index) { - final team = teams[index]; - final String? logoUrl = team['image_url']; - - return ListTile( - leading: ClipOval( - child: Container( - width: 36 * context.sf, - height: 36 * context.sf, - color: AppTheme.primaryRed.withOpacity(0.1), - child: (logoUrl != null && logoUrl.isNotEmpty) - ? CachedNetworkImage( - imageUrl: logoUrl, - fit: BoxFit.cover, - placeholder: (context, url) => Icon( - Icons.shield, - color: AppTheme.primaryRed, - size: 20 * context.sf), - errorWidget: (context, url, error) => Icon( - Icons.shield, - color: AppTheme.primaryRed, - size: 20 * context.sf), - ) - : Icon(Icons.shield, - color: AppTheme.primaryRed, - size: 20 * context.sf), - ), - ), - title: Text( - team['name'] ?? 'Sem Nome', - style: TextStyle( - color: Theme.of(context).colorScheme.onSurface, - fontWeight: FontWeight.bold), - ), - onTap: () async { - setState(() { - _selectedTeamId = team['id'].toString(); - _selectedTeamName = team['name'] ?? 'Desconhecido'; - _selectedTeamLogo = logoUrl; - _teamWins = int.tryParse( - team['wins']?.toString() ?? '0') ?? - 0; - _teamLosses = int.tryParse( - team['losses']?.toString() ?? '0') ?? - 0; - _teamDraws = int.tryParse( - team['draws']?.toString() ?? '0') ?? - 0; - }); - - await _saveSelectedTeam(); - if (context.mounted) Navigator.pop(context); - }, - ); - }, + Widget builderContent(BuildContext ctx) { + return StreamBuilder>>( + stream: _teamController.teamsStream, + builder: (context, snapshot) { + if (!snapshot.hasData && + snapshot.connectionState == ConnectionState.waiting) { + return const SizedBox( + height: 200, + child: Center(child: CircularProgressIndicator())); + } + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return SizedBox( + height: 200 * context.sf, + child: Center( + child: Text("Nenhuma equipa criada.", + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface))), ); - }, - ); - }, - ); + } + + final teams = snapshot.data!; + return ListView.builder( + shrinkWrap: true, + itemCount: teams.length, + itemBuilder: (context, index) { + final team = teams[index]; + final String? logoUrl = team['image_url']; + + return ListTile( + leading: ClipOval( + child: Container( + width: 36 * context.sf, + height: 36 * context.sf, + color: AppTheme.primaryRed.withOpacity(0.1), + child: (logoUrl != null && logoUrl.isNotEmpty) + ? CachedNetworkImage( + imageUrl: logoUrl, + fit: BoxFit.cover, + placeholder: (context, url) => Icon( + Icons.shield, + color: AppTheme.primaryRed, + size: 20 * context.sf), + errorWidget: (context, url, error) => Icon( + Icons.shield, + color: AppTheme.primaryRed, + size: 20 * context.sf), + ) + : Icon(Icons.shield, + color: AppTheme.primaryRed, + size: 20 * context.sf), + ), + ), + title: Text( + team['name'] ?? 'Sem Nome', + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold), + ), + onTap: () async { + setState(() { + _selectedTeamId = team['id'].toString(); + _selectedTeamName = team['name'] ?? 'Desconhecido'; + _selectedTeamLogo = logoUrl; + _teamWins = int.tryParse( + team['wins']?.toString() ?? '0') ?? + 0; + _teamLosses = int.tryParse( + team['losses']?.toString() ?? '0') ?? + 0; + _teamDraws = int.tryParse( + team['draws']?.toString() ?? '0') ?? + 0; + }); + + await _saveSelectedTeam(); + if (context.mounted) Navigator.pop(context); + }, + ); + }, + ); + }, + ); + } + + if (isWide) { + showDialog( + context: context, + builder: (ctx) { + return Dialog( + insetPadding: EdgeInsets.symmetric(horizontal: 200 * context.sf, vertical: 80 * context.sf), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12 * context.sf)), + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: 600 * context.sf), + child: Padding( + padding: EdgeInsets.all(16 * context.sf), + child: builderContent(ctx), + ), + ), + ); + }, + ); + } else { + showModalBottomSheet( + context: context, + backgroundColor: Theme.of(context).colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20 * context.sf)), + ), + builder: (context) => builderContent(context), + ); + } } Widget _buildHomeContent(BuildContext context) { diff --git a/lib/pages/status_page.dart b/lib/pages/status_page.dart index 00a111a..019802a 100644 --- a/lib/pages/status_page.dart +++ b/lib/pages/status_page.dart @@ -4,6 +4,7 @@ import 'package:playmaker/classe/theme.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../controllers/team_controller.dart'; +import '../controllers/active_team.dart'; import '../utils/size_extension.dart'; class StatusPage extends StatefulWidget { @@ -44,6 +45,32 @@ class _StatusPageState extends State { if (_selectedTeamId == null) { _loadSelectedTeamFallback(); } + + // Listen to global active team changes (e.g., when user marks favorite) + globalActiveTeam.addListener(_onGlobalActiveTeamChanged); + + // Se já existe um globalActiveTeam no momento da abertura da página, aplica-o + final atNow = globalActiveTeam.value; + if (atNow != null) { + _selectedTeamId = atNow.id; + _selectedTeamName = atNow.name; + _selectedTeamLogo = atNow.logo; + } + } + + void _onGlobalActiveTeamChanged() { + final at = globalActiveTeam.value; + if (!mounted) return; + + // Atualiza sempre para a equipa ativa global (favorita). Isto força a Status + // a mostrar a equipa marcada como favorita assim que o utilizador a define. + if (at != null) { + setState(() { + _selectedTeamId = at.id; + _selectedTeamName = at.name; + _selectedTeamLogo = at.logo; + }); + } } String _prefsKey(String key) { @@ -238,6 +265,12 @@ class _StatusPageState extends State { ); } + @override + void dispose() { + globalActiveTeam.removeListener(_onGlobalActiveTeamChanged); + super.dispose(); + } + List> _aggregateStats( List stats, List games, List members) { Map> aggregated = {};