tentar aresolver a home
This commit is contained in:
51
.github/copilot-instructions.md
vendored
Normal file
51
.github/copilot-instructions.md
vendored
Normal file
@@ -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<ActiveTeam?>` 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<Map<String,dynamic>>.
|
||||
- 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 —
|
||||
@@ -46,32 +46,48 @@ Future<void> loadGlobalTeam() async {
|
||||
if (userId == null) return;
|
||||
|
||||
try {
|
||||
// 1) Prefer an explicit team selection stored on the user's profile (if any)
|
||||
Map<String, dynamic>? 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<String, dynamic>.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;
|
||||
// 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<String, dynamic>.from(favTeam);
|
||||
}
|
||||
|
||||
// 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);
|
||||
// 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");
|
||||
|
||||
@@ -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<void> 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");
|
||||
}
|
||||
|
||||
@@ -219,7 +219,8 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
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<HomeScreen> {
|
||||
}
|
||||
|
||||
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<List<Map<String, dynamic>>>(
|
||||
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<List<Map<String, dynamic>>>(
|
||||
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) {
|
||||
|
||||
@@ -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<StatusPage> {
|
||||
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<StatusPage> {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
globalActiveTeam.removeListener(_onGlobalActiveTeamChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _aggregateStats(
|
||||
List<dynamic> stats, List<dynamic> games, List<dynamic> members) {
|
||||
Map<String, Map<String, dynamic>> aggregated = {};
|
||||
|
||||
Reference in New Issue
Block a user