commit b239ae3e5fed4cbaed363b756a85a96e4c839846 Author: philip Date: Thu May 14 20:13:57 2026 -0700 Initial commit: Flutter app + PHP/MySQL backend on Hostinger Replaces Firebase with a self-hosted PHP/MySQL API served from winded.prymsolutions.com. Includes full backend (schema, auth, events, teams, brackets, suggestions, stats, media, file upload) and updated Flutter repositories and domain models. Co-Authored-By: Claude Sonnet 4.6 diff --git a/.claude/agent-memory/flutter-webapp-dev/MEMORY.md b/.claude/agent-memory/flutter-webapp-dev/MEMORY.md new file mode 100644 index 0000000..78df994 --- /dev/null +++ b/.claude/agent-memory/flutter-webapp-dev/MEMORY.md @@ -0,0 +1 @@ +- [build_runner OneDrive noise](environment_build_runner.md) — spurious Access-denied errors from build_runner are not real failures; verify via `dart analyze lib`. Flutter SDK lives at `C:\flutter\bin`, not on PATH. diff --git a/.claude/agent-memory/flutter-webapp-dev/environment_build_runner.md b/.claude/agent-memory/flutter-webapp-dev/environment_build_runner.md new file mode 100644 index 0000000..62239d4 --- /dev/null +++ b/.claude/agent-memory/flutter-webapp-dev/environment_build_runner.md @@ -0,0 +1,16 @@ +--- +name: environment-build-runner +description: build_runner spurious "Access is denied" errors on .dart_tool path are environment noise, not real failures +metadata: + type: project +--- + +Running `dart run build_runner build --delete-conflicting-outputs` in this repo emits errors of the form `PathAccessException: Deletion failed, path = '.dart_tool\build_resolvers\'` and exits with code 1 — but the generation itself completes successfully and writes the `.g.dart` outputs. Verify with `dart analyze lib` afterwards. + +**Why:** The project lives under `C:\Users\phili\OneDrive\...`. OneDrive's file-sync engine holds short-lived locks on `.dart_tool` temp files, so build_runner's cleanup step fails after the actual codegen has already finished. + +**How to apply:** Treat a non-zero exit from build_runner as inconclusive on this machine. Confirm success via: +1. The build log showing `wrote N outputs` (or earlier `samed`/`output` lines). +2. `dart analyze lib` reporting `No issues found!`. + +Flutter SDK is not on PATH here — invoke it via `C:\flutter\bin\flutter.bat` / `C:\flutter\bin\dart.bat` or prepend `$env:Path = "C:\flutter\bin;$env:Path"`. diff --git a/.claude/agents/flutter-webapp-dev.md b/.claude/agents/flutter-webapp-dev.md new file mode 100644 index 0000000..b3541b6 --- /dev/null +++ b/.claude/agents/flutter-webapp-dev.md @@ -0,0 +1,102 @@ +--- +name: flutter-webapp-dev +description: "Use this agent when the user needs to build a web application, mobile application, or cross-platform app. This agent is especially valuable when the project needs to run on both mobile and web platforms simultaneously, or when the user wants a phone-optimized experience that also works well as a web app.\\n\\n\\nContext: The user wants to build a new app that works on both mobile and web.\\nuser: \"I need to build a to-do app that works on both iPhone and in the browser\"\\nassistant: \"I'll use the flutter-webapp-dev agent to design and build this cross-platform application.\"\\n\\nSince the user wants a cross-platform app (mobile + web), this is a perfect use case for the flutter-webapp-dev agent which specializes in Flutter/Dart for multi-platform deployment.\\n\\n\\n\\n\\nContext: The user asks for a mobile app.\\nuser: \"Can you build me a fitness tracking app?\"\\nassistant: \"I'm going to use the flutter-webapp-dev agent to build this fitness tracking app using Flutter and Dart so it works across iOS, Android, and web.\"\\n\\nSince the user wants a mobile app, the flutter-webapp-dev agent should be used to build it with Flutter/Dart for maximum platform reach.\\n\\n\\n\\n\\nContext: The user wants a web app but may want mobile support later.\\nuser: \"Build me a dashboard for tracking my team's project status\"\\nassistant: \"I'll launch the flutter-webapp-dev agent to build this dashboard — using Flutter will ensure it looks great on web and can easily be extended to mobile.\"\\n\\nEven for a primarily web-focused request, the flutter-webapp-dev agent adds value by building with Flutter so the app is inherently cross-platform from day one.\\n\\n" +model: opus +color: red +memory: project +--- + +You are a skilled and experienced full-stack web and mobile application developer with deep expertise across all modern programming languages and frameworks including JavaScript/TypeScript, React, Vue, Angular, Node.js, Python, Swift, Kotlin, and many others. You are versatile and pragmatic — you choose the right tool for the job. + +However, when tasked with building mobile applications or cross-platform applications that need to run on both mobile and web, your strong preference is to use **Dart and Flutter**. This is because Flutter enables you to write one codebase that deploys seamlessly to iOS, Android, and web — producing phone-optimized experiences that also look polished and professional as web apps. + +## Your Development Philosophy + +- **Mobile-first, web-compatible**: When building with Flutter, you design layouts and UX flows that feel native and natural on a phone, then ensure they scale gracefully to larger web viewports. +- **Single codebase, maximum reach**: You leverage Flutter's cross-platform capabilities to avoid duplicating logic or maintaining separate codebases. +- **Clean, idiomatic Dart**: You write modern, null-safe Dart code using best practices — proper use of async/await, streams, providers, and well-structured widget trees. +- **Pragmatic tool selection**: For purely web-only projects with no mobile requirement, you are comfortable recommending and using other frameworks (React, Vue, etc.) when Flutter would be overkill. + +## Flutter & Dart Best Practices You Follow + +1. **State Management**: Prefer Riverpod or Provider for state management; use BLoC/Cubit for complex business logic. Avoid setState except for simple, localized UI state. +2. **Project Structure**: Organize code into clear layers — presentation (widgets/screens), application (controllers/notifiers), domain (models/entities), and infrastructure (repositories/services). +3. **Responsive Design**: Use `LayoutBuilder`, `MediaQuery`, `Flexible`, and `Expanded` to build responsive UIs that adapt to different screen sizes. Use breakpoints to differentiate phone, tablet, and desktop/web layouts. +4. **Navigation**: Use GoRouter for declarative, URL-friendly navigation that works well on both mobile and web. +5. **Performance**: Minimize widget rebuilds, use `const` constructors wherever possible, lazy-load heavy content, and profile with Flutter DevTools. +6. **Platform Adaptability**: Use `kIsWeb` and `Platform` checks when platform-specific behavior is needed. Adapt input handling (touch vs mouse/keyboard) appropriately. +7. **Theming**: Define a consistent `ThemeData` with color schemes, typography, and component themes from the outset. +8. **Testing**: Write unit tests for business logic, widget tests for UI components, and integration tests for critical user flows. +9. **Packages**: Favor well-maintained, popular pub.dev packages. Always check null-safety compatibility and platform support before adding a dependency. + +## How You Work + +1. **Clarify requirements first**: Before writing code, confirm the target platforms, key features, authentication needs, backend/API requirements, and design preferences. Ask targeted questions to avoid rework. +2. **Plan before coding**: Outline your architecture, data models, and key screens/components before diving into implementation. +3. **Deliver complete, runnable code**: Provide full file contents with proper imports, not just snippets, unless a snippet is explicitly what's needed. +4. **Explain your decisions**: Briefly explain why you chose a particular approach, package, or pattern so the user can learn and maintain the code. +5. **Anticipate next steps**: After completing a feature or component, mention logical next steps or potential improvements. +6. **Handle errors gracefully**: Include proper error handling, loading states, and empty states in all UI components. + +## When NOT to Use Flutter + +Be transparent and recommend alternatives when: +- The project is a pure web app with no mobile requirement and SEO is critical (suggest React/Next.js or similar) +- The team has zero Flutter experience and timeline is extremely tight +- Very specific native platform integrations are required that Flutter doesn't support well + +In these cases, use your expertise in the appropriate technology instead. + +## Output Quality Standards + +- All code must be null-safe Dart (using sound null safety) +- Follow the official Dart style guide and use `dart format` conventions +- Widget names should be descriptive PascalCase; variables and functions should be camelCase +- Include comments for non-obvious logic +- Ensure code compiles and runs without modification whenever possible +- When providing multi-file solutions, clearly label each file with its path + +**Update your agent memory** as you discover project-specific patterns, architectural decisions, custom widgets, state management approaches, and platform-specific requirements. This builds institutional knowledge across conversations. + +Examples of what to record: +- Custom reusable widgets and their locations +- State management patterns and providers established in the project +- Navigation structure and route definitions +- API integration patterns and service layer conventions +- Theming decisions and design tokens +- Platform-specific workarounds or configurations + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `C:\Users\phili\OneDrive\Projects\winded\.claude\agent-memory\flutter-webapp-dev\`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files + +What to save: +- Stable patterns and conventions confirmed across multiple interactions +- Key architectural decisions, important file paths, and project structure +- User preferences for workflow, tools, and communication style +- Solutions to recurring problems and debugging insights + +What NOT to save: +- Session-specific context (current task details, in-progress work, temporary state) +- Information that might be incomplete — verify against project docs before writing +- Anything that duplicates or contradicts existing CLAUDE.md instructions +- Speculative or unverified conclusions from reading a single file + +Explicit user requests: +- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions +- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files +- When the user corrects you on something you stated from memory, you MUST update or remove the incorrect entry. A correction means the stored memory is wrong — fix it at the source before continuing, so the same mistake does not repeat in future conversations. +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time. diff --git a/.claude/agents/responsive-ui-designer.md b/.claude/agents/responsive-ui-designer.md new file mode 100644 index 0000000..f24a0d0 --- /dev/null +++ b/.claude/agents/responsive-ui-designer.md @@ -0,0 +1,116 @@ +--- +name: responsive-ui-designer +description: "Use this agent when you need expert guidance on web design, UI/UX decisions, color palette selection, typography pairing, responsive layout strategies, or visual design system creation. This agent is ideal for projects requiring timeless, modern design that works across all screen sizes and devices.\\n\\n\\nContext: The user is starting a new web project and needs design direction.\\nuser: \"I'm building a SaaS dashboard for project management. Can you help me design it?\"\\nassistant: \"I'll launch the responsive-ui-designer agent to craft a comprehensive design strategy for your SaaS dashboard.\"\\n\\nSince the user needs expert web design guidance including layout, color, typography, and responsiveness, use the responsive-ui-designer agent to provide a thorough design plan.\\n\\n\\n\\n\\nContext: The user has a partially built website and wants to improve its visual appeal and responsiveness.\\nuser: \"My website looks okay on desktop but terrible on mobile, and the color scheme feels outdated.\"\\nassistant: \"Let me use the responsive-ui-designer agent to diagnose the responsive design issues and propose an updated, future-proof color system.\"\\n\\nSince this involves responsive design fixes and color modernization, the responsive-ui-designer agent is the right tool to provide detailed, actionable design recommendations.\\n\\n\\n\\n\\nContext: The user is selecting fonts and colors for a brand-new project.\\nuser: \"What fonts and colors should I use for a fintech app targeting young professionals?\"\\nassistant: \"I'll invoke the responsive-ui-designer agent to recommend a complementary font pairing and color palette tailored to your audience and industry.\"\\n\\nFont and color selection requires expertise in color theory and typography — exactly what the responsive-ui-designer agent specializes in.\\n\\n" +model: sonnet +color: orange +memory: project +--- + +You are a seasoned Senior Graphic and Web Designer with over 15 years of experience in crafting beautiful, functional, and future-proof digital products. Your expertise spans the full spectrum of visual design including color theory, typography, layout systems, design tokens, and responsive UI/UX patterns. You have a deep familiarity with modern design paradigms such as Material Design 3, Apple Human Interface Guidelines, and Fluent Design, yet you transcend any single system to create original, timeless work tailored to each project's unique needs. + +## Core Philosophy +- **Timeless over trendy**: Design choices should remain relevant for 3–5+ years. Avoid fads; favor enduring principles. +- **Function informs form**: Every visual decision must serve usability and user experience first. +- **Consistency is king**: Establish scalable systems — not one-off decisions — so the design grows gracefully. +- **Accessibility by default**: Color contrast, font sizing, and interaction patterns must meet or exceed WCAG 2.1 AA standards. + +## Your Expertise Areas + +### Color Theory & Palette Design +- Build harmonious palettes using color theory principles: complementary, analogous, triadic, split-complementary, and monochromatic schemes. +- Define full color systems including primary, secondary, accent, neutral/gray scales, semantic colors (success, warning, error, info), and surface/background tokens. +- Ensure sufficient contrast ratios for accessibility (minimum 4.5:1 for body text, 3:1 for large text and UI components). +- Account for light and dark mode variations. +- Recommend specific hex/HSL values with rationale tied to the project's brand personality and target audience. + +### Typography +- Pair fonts with intentionality: one display/heading font + one body font is a strong foundation; introduce a third sparingly for accent or code. +- Consider font personality, x-height, legibility at small sizes, and licensing (prefer Google Fonts, Adobe Fonts, or open-source options unless otherwise specified). +- Define a clear typographic scale (e.g., using a modular scale ratio like 1.25 or 1.333) covering display, H1–H6, body, caption, and label sizes. +- Specify line-height, letter-spacing, and font-weight pairings for each text style. +- Ensure fonts render well across operating systems and browsers. + +### Responsive Design +- Design with a **mobile-first** methodology, progressively enhancing for tablet and desktop. +- Define clear breakpoints (typically: mobile <640px, tablet 640–1024px, desktop >1024px, wide >1280px) and explain layout behavior at each. +- Use fluid typography and spacing (clamp(), relative units, CSS custom properties) to minimize jarring layout jumps. +- Recommend appropriate layout patterns: single-column → sidebar → multi-column as viewport expands. +- Address touch targets (minimum 44×44px), thumb-zone accessibility on mobile, and hover/focus states for pointer devices. + +### Modern UI Design Patterns +- Leverage current best practices: design tokens, component-based thinking, spacing systems (4px or 8px base grid), elevation/shadow systems, and motion principles. +- Recommend appropriate UI components and interaction patterns for the use case. +- Consider micro-interactions and transitions that enhance perceived performance and delight without distraction. + +## How You Work + +1. **Discover**: Begin by asking clarifying questions if critical information is missing — target audience, brand personality (words like "bold", "calm", "professional", "playful"), industry/vertical, existing brand assets, technical stack constraints, and timeline. + +2. **Strategize**: Before jumping to specifics, articulate the design strategy — what emotional response should the design evoke? What are the primary user goals? + +3. **Specify**: Provide concrete, actionable recommendations with exact values — not vague guidance. Include: + - Named color palette with hex codes and usage rules + - Font names, weights, sizes, and pairing rationale + - Spacing and layout grid specifications + - Breakpoint behavior descriptions + - Component-level guidance when relevant + +4. **Rationalize**: Explain *why* each major decision was made. Help the user understand the design thinking so they can make informed decisions and extend the system confidently. + +5. **Anticipate**: Proactively flag potential pitfalls — e.g., a color that looks great on desktop but loses contrast on OLED mobile screens, or a display font that degrades at small sizes. + +## Output Format +Structure your responses clearly using markdown. Use sections, tables for color palettes and type scales, and code snippets (CSS custom properties, design tokens) where they add value. When presenting options, offer 2–3 curated alternatives rather than an overwhelming list. + +## Quality Checks +Before finalizing any recommendation, verify: +- [ ] Color contrast meets WCAG AA minimums +- [ ] Font choices are web-safe and performant (check Google Fonts load times if applicable) +- [ ] The design system is extensible — new components can be added without breaking the visual language +- [ ] Responsive behavior is explicitly defined at each breakpoint +- [ ] The design will not feel dated in 3–5 years + +**Update your agent memory** as you learn details about each project you work on. This builds institutional knowledge that makes future design iterations faster and more consistent. + +Examples of what to record: +- Project name, brand personality descriptors, and target audience +- Finalized color palette tokens and their intended usage +- Chosen font pairings and typographic scale ratios +- Breakpoints and layout grid decisions +- Any design constraints (tech stack, accessibility requirements, client preferences) +- Design decisions made and the rationale behind them + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `C:\Users\phili\OneDrive\Projects\winded\.claude\agent-memory\responsive-ui-designer\`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files + +What to save: +- Stable patterns and conventions confirmed across multiple interactions +- Key architectural decisions, important file paths, and project structure +- User preferences for workflow, tools, and communication style +- Solutions to recurring problems and debugging insights + +What NOT to save: +- Session-specific context (current task details, in-progress work, temporary state) +- Information that might be incomplete — verify against project docs before writing +- Anything that duplicates or contradicts existing CLAUDE.md instructions +- Speculative or unverified conclusions from reading a single file + +Explicit user requests: +- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions +- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files +- When the user corrects you on something you stated from memory, you MUST update or remove the incorrect entry. A correction means the stored memory is wrong — fix it at the source before continuing, so the same mistake does not repeat in future conversations. +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time. diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..044e439 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,13 @@ +{ + "permissions": { + "allow": [ + "PowerShell($env:PATH = \"C:\\\\flutter\\\\bin;C:\\\\Users\\\\phili\\\\.local\\\\bin;\" + $env:PATH; Set-Location \"C:\\\\Users\\\\phili\\\\OneDrive\\\\Projects\\\\winded\"; & \"C:\\\\flutter\\\\bin\\\\flutter.bat\" analyze lib/ 2>&1)", + "PowerShell($env:PATH = \"C:\\\\flutter\\\\bin;C:\\\\Users\\\\phili\\\\.local\\\\bin;\" + $env:PATH; Set-Location \"C:\\\\Users\\\\phili\\\\OneDrive\\\\Projects\\\\winded\"; & \"C:\\\\flutter\\\\bin\\\\flutter.bat\" run -d chrome 2>&1)", + "PowerShell($env:PATH = \"C:\\\\flutter\\\\bin;\" + $env:PATH; & \"C:\\\\flutter\\\\bin\\\\flutter.bat\" --version)", + "PowerShell($env:PATH = \"C:\\\\flutter\\\\bin;\" + $env:PATH; & \"C:\\\\flutter\\\\bin\\\\flutter.bat\" analyze lib/)", + "PowerShell(flutter *)", + "PowerShell(& \"C:\\\\flutter\\\\bin\\\\dart.bat\" run build_runner build --delete-conflicting-outputs)", + "Bash(Get-ChildItem C:\\\\Users\\\\phili\\\\OneDrive\\\\Projects\\\\winded *)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3820a95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ +/coverage/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..4ed0c94 --- /dev/null +++ b/.metadata @@ -0,0 +1,30 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "48c32af0345e9ad5747f78ddce828c7f795f7159" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + - platform: web + create_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + base_revision: 48c32af0345e9ad5747f78ddce828c7f795f7159 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e878dca --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,108 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**Winded** is a soccer/sports community app for organizing pick-up games, managing tournaments, and building a local league community. The app is **mobile-first, cross-platform** (iOS, Android, Web) built with Flutter/Dart. No code has been written yet — this is in active planning and early development. + +## Tech Stack + +- **Frontend**: Flutter (Dart), targeting iOS, Android, and Web +- **Backend**: Firebase (Auth, Firestore, Storage, Cloud Functions) +- **State Management**: Riverpod (preferred) or BLoC for complex logic +- **Navigation**: GoRouter (declarative, URL-friendly, works on mobile + web) +- **Charts**: fl_chart package for stats/leaderboards +- **Ads** (future): Google AdMob + +## Planned Folder Structure + +``` +lib/ + ├── core/ # App-wide config, themes, constants, routing + ├── features/ + │ ├── auth/ # Firebase Auth, login, registration + │ ├── events/ # Event listing, detail, countdown, registration + │ ├── brackets/ # Tournament bracket generation and viewing + │ ├── teams/ # Team profiles, rosters + │ ├── stats/ # Player/team leaderboards and stats + │ ├── media/ # Social media links, YouTube embeds, highlights + │ ├── suggestions/ # Suggestion box, admin review + │ └── ads/ # Ad integration (future phase) + ├── shared/ # Reusable widgets, utilities + └── main.dart +``` + +Each feature follows a layered structure: **presentation** (widgets/screens) → **application** (controllers/notifiers) → **domain** (models/entities) → **infrastructure** (repositories/services). + +## User Roles + +- **Guest**: View events, brackets, stats, teams +- **Registered User**: Register for events, submit suggestions, vote in polls +- **Admin**: Create/edit events, manage brackets, approve teams, post ads + +## Core Features + +1. **Authentication** — Email/password + optional Google/Apple sign-in via Firebase Auth +2. **Events** — List, detail page, countdown timer, location map, registration button; data model includes title, description, date, location, registration_deadline, teams_registered +3. **Bracket System** — Most complex feature; auto-generate or admin-edit brackets, real-time updates via Firestore; store as tree structure or matches-by-round; custom bracket UI widget +4. **Teams Page** — Team profiles (name, logo, wins, losses, players), roster view +5. **Stats** — Player and team leaderboards, filters by event, optional charts +6. **Polls / Events Polling** — Community voting (one vote per user), user-submitted activity requests (admin approval workflow), ability to block user requests +7. **Suggestion Box** — Text form with anonymous option, Firestore `suggestions` collection, admin dashboard for review +8. **Private Clubs** — Password-protected clubs so groups can use the app privately; other users can create their own clubs +9. **Registration Tab** — Shows numerator (people registered) / denominator (preferred headcount); registration indicates intent, does not restrict extras +10. **Media Promotion** — Social media links, YouTube embed support, Instagram/highlight content +11. **Advertisements** (future) — AdMob banners/interstitials; add only after core UX is polished, never on bracket screen + +## Flutter Conventions + +- All Dart code must use **sound null safety** +- Follow `dart format` conventions; widget names PascalCase, variables/functions camelCase +- Use `const` constructors wherever possible to minimize rebuilds +- Responsive layouts via `LayoutBuilder`, `MediaQuery`, `Flexible`, `Expanded`; breakpoints: mobile <640px, tablet 640–1024px, desktop >1024px +- Use `kIsWeb` and `Platform` checks for platform-specific behavior +- Define `ThemeData` with full color scheme, typography, and component themes from the start + +## Common Commands + +Once the Flutter project is initialized: + +```bash +# Run on a connected device/emulator +flutter run + +# Run on Chrome (web) +flutter run -d chrome + +# Build for web (production) +flutter build web --release + +# Run tests +flutter test + +# Run a single test file +flutter test test/path/to/test_file.dart + +# Format code +dart format lib/ + +# Analyze code +dart analyze + +# Get/update packages +flutter pub get +``` + +## Custom Agents + +Two specialized sub-agents are configured in `.claude/agents/`: + +- **`flutter-webapp-dev`** — Use for building features, writing Flutter/Dart code, architecture decisions, and any cross-platform implementation work. Prefers Riverpod, GoRouter, and feature-layered architecture. +- **`responsive-ui-designer`** — Use for design decisions: color palettes, typography pairings, responsive layout strategies, design tokens, and visual design system creation. Follows WCAG 2.1 AA accessibility standards. + +## MVP Scope + +Build in this order: Auth → Events → Registration → Bracket viewing → Teams page → Stats → Suggestion box → Ads (last). + +Bracket generation and dynamic UI rendering is the biggest technical challenge — design the Firestore data structure for brackets before building any bracket UI. diff --git a/Image Ideas/IMG_0004.png b/Image Ideas/IMG_0004.png new file mode 100644 index 0000000..ff716ce Binary files /dev/null and b/Image Ideas/IMG_0004.png differ diff --git a/Image Ideas/IMG_0005.png b/Image Ideas/IMG_0005.png new file mode 100644 index 0000000..dfc922c Binary files /dev/null and b/Image Ideas/IMG_0005.png differ diff --git a/Image Ideas/IMG_0006.png b/Image Ideas/IMG_0006.png new file mode 100644 index 0000000..db1e1f3 Binary files /dev/null and b/Image Ideas/IMG_0006.png differ diff --git a/Image Ideas/IMG_0008.png b/Image Ideas/IMG_0008.png new file mode 100644 index 0000000..9df1e26 Binary files /dev/null and b/Image Ideas/IMG_0008.png differ diff --git a/Image Ideas/Screenshot 2026-05-14 023706.png b/Image Ideas/Screenshot 2026-05-14 023706.png new file mode 100644 index 0000000..0cfd49a Binary files /dev/null and b/Image Ideas/Screenshot 2026-05-14 023706.png differ diff --git a/Image Ideas/ShadowOakLogo.jpg b/Image Ideas/ShadowOakLogo.jpg new file mode 100644 index 0000000..5995c46 Binary files /dev/null and b/Image Ideas/ShadowOakLogo.jpg differ diff --git a/Image Ideas/light-purple-color_color.png b/Image Ideas/light-purple-color_color.png new file mode 100644 index 0000000..8c12fb1 Binary files /dev/null and b/Image Ideas/light-purple-color_color.png differ diff --git a/Phases and text docs/Grok Idea Winded.txt b/Phases and text docs/Grok Idea Winded.txt new file mode 100644 index 0000000..8f3eae0 --- /dev/null +++ b/Phases and text docs/Grok Idea Winded.txt @@ -0,0 +1,48 @@ +can you help me write an application structure, feature set and architecture document that I can hand to a developer (or AI coding platform) where it will include all aspects of the application where they can get to coding the app? Ask me for clarification if there is something that is not clear and also ask me if you think there is something i am missing or a gap in my thinking. + +I am on a team of soccer players and enthusiests. We love to get together and have 'pick-up games' at the local parks. Our idea is to form an official league/community fo ffellow soccer players where they can sign-up for tournaments and manage the games and players. I want this to be writen in Dart with flutter. I want this to be mobile first but work cross platform with ios android and a web browser.I want to host this on a commercial hosting platform such as HOSTINGER. take security, optimisation (both for speed and site rankings ie.seo) and a modern UX design into consideration. + +here is the what i am thinking for an app. I would like for the app to be able to promote media platforms, promote events, Allow registration for tournaments, Be able to form brackets and view those brackets (Only Admin can change/edit brackets). I also want it to have a suggestion "box". I also want to be able to have player stats on the app so player can know how other players did. I also want to be able to have a teams page so that people can look at the teams and be able to look at the players on the teams. It would also be nice to be able to put possible advertisments on the app later down the line. + +Here are some of the pages we want, but feel free to make suggestions on how modern websites function: + +1.Registration Tab +There should be an option to register +Registration is only meant to indicate expected people going, however should not restrict extras +Numerator is people who voted to go +Denominator is our preferred number of people going +A tab should exist to explain the function of this tab and reasoning behind denominator + + +2.Events polling +Community polls should be available +A person may only vote once +Options should be provided +Users should be able to request an activity, and we should be able to approve of that activity being added to the poll, edit and add, or ignore +Users should be limited in possible requests +Blocking user requests should be possible + +3.Public and Private +We should have the ability to host events and use its features exclusively for us. This could be done by having a password necessary to join a certain “club” and then only giving access to said club +Private club modes +This could be used to allow others to create their own clubs as well + +4. Make a default home tab so that there is a place where you can access the tabs from the home page. Kind of like when you start the canvas app and you have different widgets for the different classes. + +I put all of the files you need into the directory that you can access, double and triple check the files to get a good Idea on what your task is. + +Use the suggested backend stuff and we will try to run it from there for now. + +5.The brackets admin tab should have a option to randomly assign teams that signed up for that specific event to the bracket. I would also like to create a form for a person to create a team and assign him/ her a managment role for +that teamm where that can enter playernames, numbers, team name, upload a logo, and contact info ie mandatory email and optional phone number. also create a profile page for a team where visitors to the site can view team profiles +and stats. I also want the events tab to havve a category of events so that I can make an event for tournaments or an event for a regular pick up scrimmage. + +6.Questions: how will teams track goals and stats? wil they track their own? Are you going to give everyone on the team a login so they can access the teams page and they can make a profile? + +7.Roles: Player: will be able to create a profile for themselves so they can write their own bio, upload a profile pic, and possibly register for teams/events. +Manager: a manager will be able to create a team using the form that was created the form should be sent to an admin so they can accept or deny the team. They can decline or accept a player into their team if said player requests to join. they can also create the team bio, logo, and stats.' +Viewer: a viewer will have no editing permissions. They are able to look at team profiles and player profiles. +Admin: Admins are able to edit anything anywhere, anytime. The admins are also able to acccept a team. The admin is able to kick people and teams out of tournaments if needed. They can also see who is registered into any event. + +8.Assigning teams to tournaments: once registered, calculate odds that a team would win agains another team but only thhe admin can see the odds. +Pickup: I would also like to have the option so that if someone would like to individualy register for a pickup event, they can. \ No newline at end of file diff --git a/Phases and text docs/Phases.txt b/Phases and text docs/Phases.txt new file mode 100644 index 0000000..1b9013f --- /dev/null +++ b/Phases and text docs/Phases.txt @@ -0,0 +1,302 @@ +Perfect — based on your listed core features, here’s a structured Flutter development roadmap tailored to your application. + +This looks like a sports/tournament management app (brackets + teams + stats + events), so I’ll structure it like a scalable production app. + +🏆 Flutter Roadmap – Tournament / Sports Event App +🧠 Phase 1: Planning & Architecture +1️⃣ Define User Roles + +You likely have: + +Guest users (view events, brackets, stats) + +Registered users (register for events, submit suggestions) + +Admins (create events, manage brackets, teams, ads) + +2️⃣ Choose Tech Stack +📱 Frontend + +Flutter (iOS + Android) + +State management: Riverpod or BLoC (recommended for scalability) + +🔥 Backend (Recommended) + +Firebase + +Authentication + +Firestore (database) + +Storage (media) + +Cloud Functions (bracket logic) + +Analytics + +Alternative: Custom REST API (Node.js, Django, etc.) + +🏗 Phase 2: App Architecture Setup +Suggested Folder Structure +lib/ + ├── core/ + ├── features/ + │ ├── auth/ + │ ├── events/ + │ ├── brackets/ + │ ├── teams/ + │ ├── stats/ + │ ├── media/ + │ ├── suggestions/ + │ └── ads/ + ├── shared/ + └── main.dart +🚀 Phase 3: Feature-by-Feature Development Plan +🟢 1. Authentication & Registration +Features: + +Email/password login + +Social login (Google/Apple optional) + +Event registration form + +Backend: + +Firebase Auth + +Firestore user collection + +🟡 2. Promote Media Platforms +Features: + +Social media links page + +Embedded posts (optional) + +Highlight videos + +Integrations: + +Instagram/Twitter links + +YouTube embed support + +Example: + +Embed content from Instagram + +Link to YouTube + +🔵 3. Promote Events +Features: + +Events list page + +Event detail page + +Countdown timer + +Location map + +Register button + +Data Model: +Event: +- title +- description +- date +- location +- registration_deadline +- teams_registered +🟣 4. Bracket Building & Viewing (Core Feature) +This is the most complex part. +Features: + +Auto-generate bracket + +Manual admin editing + +Live score updates + +View by round + +Share bracket + +Implementation Plan: + +Create bracket data model + +Generate rounds dynamically + +Create custom bracket UI widget + +Real-time updates via Firestore + +You may: + +Store bracket as tree structure + +Or store matches by round + +🟠 5. Teams Page +Features: + +List of teams + +Team profile page + +Team stats + +Roster + +Data Model: +Team: +- name +- logo +- wins +- losses +- players +🔴 6. Stats Viewing +Features: + +Leaderboards + +Player stats + +Team rankings + +Filters by event + +Optional: + +Charts (using fl_chart package) + +🟤 7. Suggestion Box +Features: + +Text submission form + +Anonymous option + +Admin dashboard for review + +Backend: + +Firestore collection: suggestions + +Optional email notifications + +💰 8. Possible Advertisements +Integration: + +Google AdMob + +Banner ads + +Interstitial ads + +Important: + +Add only after core UX is polished + +Avoid cluttering bracket screen + +🧪 Phase 4: Testing + +Unit testing (logic) + +Widget testing (UI) + +Integration testing + +Test on: + +iPhone + +Android devices + +🛡 Phase 5: Admin Panel + +Options: + +Build admin inside Flutter app + +Create separate web dashboard + +Use Firebase Console (early stage) + +Admin abilities: + +Create events + +Edit brackets + +Approve teams + +Post ads + +📊 Suggested Development Timeline (12 Weeks Example) +Weeks Focus +1–2 Setup + Authentication +3–4 Events + Registration +5–6 Teams Page +7–8 Bracket System +9 Stats +10 Suggestion Box +11 Ads Integration +12 Testing & Polish +🧩 Advanced Enhancements (Future) + +Push notifications + +Live game updates + +In-app chat + +Player profiles + +Dark mode + +Web version + +🎯 MVP (Minimum Viable Product) + +If you want to launch fast: + +Build only: + +Auth + +Events + +Registration + +Bracket viewing + +Teams page + +Add: + +Stats + Ads later + +📌 Final Advice + +Your biggest technical challenge will be: + +🏆 Bracket generation and dynamic UI rendering + +I recommend designing that data structure first before building UI. + +If you want, I can next: + +Design your Firestore database schema + +Create a Flutter architecture diagram + +Write starter Flutter code structure + +Or break this into a semester project plan + +Just tell me what level you're building this for (class project? startup? production app?). 🚀 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..6b3adc2 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# winded + +Soccer community tournament and pick-up game management app + +## Getting Started + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) +- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) +- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) + +For help getting started with Flutter development, view the +[online documentation](https://docs.flutter.dev/), which offers tutorials, +samples, guidance on mobile development, and a full API reference. diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..0d29021 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,28 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at https://dart.dev/lints. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..31cb83c --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,47 @@ +plugins { + id("com.android.application") + // START: FlutterFire Configuration + id("com.google.gms.google-services") + // END: FlutterFire Configuration + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.winded.winded" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.winded.winded" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000..4d75c44 --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,29 @@ +{ + "project_info": { + "project_number": "611552481312", + "project_id": "winded-app", + "storage_bucket": "winded-app.firebasestorage.app" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:611552481312:android:da656fe3aaad89db5aac45", + "android_client_info": { + "package_name": "com.winded.winded" + } + }, + "oauth_client": [], + "api_key": [ + { + "current_key": "AIzaSyCGng1CA9UeJKPcWRrQZwd6G3fqcEFImPE" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..47b8f1a --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/winded/winded/MainActivity.kt b/android/app/src/main/kotlin/com/winded/winded/MainActivity.kt new file mode 100644 index 0000000..6a77c8e --- /dev/null +++ b/android/app/src/main/kotlin/com/winded/winded/MainActivity.kt @@ -0,0 +1,5 @@ +package com.winded.winded + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..174f408 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,29 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + // START: FlutterFire Configuration + id("com.google.gms.google-services") version("4.3.15") apply false + // END: FlutterFire Configuration + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") diff --git a/assets/images/shadow_oak_logo.jpg b/assets/images/shadow_oak_logo.jpg new file mode 100644 index 0000000..5995c46 Binary files /dev/null and b/assets/images/shadow_oak_logo.jpg differ diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..4e79ec6 --- /dev/null +++ b/firebase.json @@ -0,0 +1 @@ +{"flutter":{"platforms":{"android":{"default":{"projectId":"winded-app","appId":"1:611552481312:android:da656fe3aaad89db5aac45","fileOutput":"android/app/google-services.json"}},"dart":{"lib/firebase_options.dart":{"projectId":"winded-app","configurations":{"android":"1:611552481312:android:da656fe3aaad89db5aac45","ios":"1:611552481312:ios:2180c26facd38a915aac45","web":"1:611552481312:web:23ed1101037509755aac45"}}}}}} \ No newline at end of file diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..391a902 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..592ceee --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..a43a15f --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,620 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.winded.winded; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.winded.winded.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.winded.winded.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.winded.winded.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.winded.winded; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.winded.winded; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..c30b367 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,16 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..99407eb --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,70 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Winded + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + winded + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/Runner/SceneDelegate.swift b/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/core/admin/admin_guard.dart b/lib/core/admin/admin_guard.dart new file mode 100644 index 0000000..59b9b67 --- /dev/null +++ b/lib/core/admin/admin_guard.dart @@ -0,0 +1,24 @@ +import '../../features/auth/domain/app_user.dart'; +import '../../features/profile/domain/user_profile.dart'; + +/// Hardcoded admin allow-list for the MVP. Email match is the primary signal +/// because the hardcoded admin doesn't carry a Firestore role document. +const Set _adminEmails = {'philip@theguzmanfamily.com'}; + +/// Returns true if [user] is on the email allow-list. Primary entry point — +/// most callers only have an [AppUser] in hand. +bool isAdmin(AppUser? user) { + if (user == null) return false; + final email = user.email.trim().toLowerCase(); + if (email.isEmpty) return false; + return _adminEmails.contains(email); +} + +/// Returns true when admin status is established either by email allow-list +/// or by a Firestore profile document carrying [UserRole.admin]. Use this in +/// places where a profile is already loaded so a future Firestore-driven +/// admin grant works without a code change. +bool isAdminWithProfile(AppUser? user, UserProfile? profile) { + if (isAdmin(user)) return true; + return profile?.role == UserRole.admin; +} diff --git a/lib/core/api/api_client.dart b/lib/core/api/api_client.dart new file mode 100644 index 0000000..078f8df --- /dev/null +++ b/lib/core/api/api_client.dart @@ -0,0 +1,122 @@ +import 'dart:convert'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'api_client.g.dart'; + +// --------------------------------------------------------------------------- +// Configuration — set this to your Hostinger domain before building. +// --------------------------------------------------------------------------- +const String kApiBase = 'https://winded.prymsolutions.com/api'; + +const String _tokenKey = 'winded_auth_token'; + +// --------------------------------------------------------------------------- +// ApiClient +// --------------------------------------------------------------------------- + +class ApiClient { + ApiClient(this._storage); + + final FlutterSecureStorage _storage; + + // --- Token management --- + + Future get token => _storage.read(key: _tokenKey); + + Future saveToken(String token) => + _storage.write(key: _tokenKey, value: token); + + Future clearToken() => _storage.delete(key: _tokenKey); + + // --- HTTP helpers --- + + Future> _headers({bool auth = true}) async { + final headers = {'Content-Type': 'application/json'}; + if (auth) { + final t = await token; + if (t != null) headers['Authorization'] = 'Bearer $t'; + } + return headers; + } + + Uri _uri(String path, [Map? params]) { + final uri = Uri.parse('$kApiBase$path'); + return params != null ? uri.replace(queryParameters: params) : uri; + } + + Future> get( + String path, { + Map? params, + bool auth = true, + }) async { + final res = await http.get(_uri(path, params), headers: await _headers(auth: auth)); + return _parse(res); + } + + Future> post( + String path, + Map body, { + bool auth = true, + }) async { + final res = await http.post( + _uri(path), + headers: await _headers(auth: auth), + body: jsonEncode(body), + ); + return _parse(res); + } + + Future> put( + String path, + Map body, { + Map? params, + }) async { + final res = await http.put( + _uri(path, params), + headers: await _headers(), + body: jsonEncode(body), + ); + return _parse(res); + } + + Future> delete( + String path, { + Map? params, + }) async { + final res = await http.delete(_uri(path, params), headers: await _headers()); + return _parse(res); + } + + Map _parse(http.Response res) { + final body = jsonDecode(res.body) as Map; + if (res.statusCode >= 400) { + throw ApiException( + message: (body['error'] as String?) ?? 'Unknown error', + statusCode: res.statusCode, + ); + } + return body; + } +} + +class ApiException implements Exception { + const ApiException({required this.message, required this.statusCode}); + final String message; + final int statusCode; + + @override + String toString() => 'ApiException($statusCode): $message'; +} + +// --------------------------------------------------------------------------- +// Providers +// --------------------------------------------------------------------------- + +@Riverpod(keepAlive: true) +ApiClient apiClient(ApiClientRef ref) { + return ApiClient(const FlutterSecureStorage()); +} diff --git a/lib/core/api/api_client.g.dart b/lib/core/api/api_client.g.dart new file mode 100644 index 0000000..b8b2d24 --- /dev/null +++ b/lib/core/api/api_client.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package, deprecated_member_use + +part of 'api_client.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$apiClientHash() => r'api_client_hash_placeholder'; + +/// See also [apiClient]. +@ProviderFor(apiClient) +final apiClientProvider = Provider.internal( + apiClient, + name: r'apiClientProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$apiClientHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef ApiClientRef = ProviderRef; diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart new file mode 100644 index 0000000..61c30da --- /dev/null +++ b/lib/core/router/app_router.dart @@ -0,0 +1,245 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../features/admin/presentation/admin_shell.dart'; +import '../../features/admin/presentation/brackets/admin_bracket_form_screen.dart'; +import '../../features/admin/presentation/brackets/admin_brackets_screen.dart'; +import '../../features/admin/presentation/events/admin_event_form_screen.dart'; +import '../../features/admin/presentation/events/admin_events_screen.dart'; +import '../../features/admin/presentation/pending/admin_pending_screen.dart'; +import '../../features/admin/presentation/suggestions/admin_suggestions_screen.dart'; +import '../../features/admin/presentation/teams/admin_team_form_screen.dart'; +import '../../features/admin/presentation/teams/admin_teams_screen.dart'; +import '../../features/auth/application/auth_notifier.dart'; +import '../../features/auth/presentation/login_screen.dart'; +import '../../features/auth/presentation/register_screen.dart'; +import '../../features/brackets/presentation/bracket_detail_screen.dart'; +import '../../features/brackets/presentation/brackets_screen.dart'; +import '../../features/events/presentation/event_detail_screen.dart'; +import '../../features/events/presentation/events_screen.dart'; +import '../../features/media/presentation/media_screen.dart'; +import '../../features/profile/application/profile_notifier.dart'; +import '../../features/profile/domain/user_profile.dart'; +import '../../features/profile/presentation/manager_dashboard_screen.dart'; +import '../../features/profile/presentation/my_profile_screen.dart'; +import '../../features/profile/presentation/player_profile_screen.dart'; +import '../../features/stats/presentation/stats_screen.dart'; +import '../../features/suggestions/presentation/suggestions_screen.dart'; +import '../../features/teams/presentation/create_team_screen.dart'; +import '../../features/teams/presentation/team_detail_screen.dart'; +import '../../features/teams/presentation/teams_screen.dart'; +import '../admin/admin_guard.dart'; +import '../shell/main_shell.dart'; + +/// Routes that an unauthenticated user is allowed to visit. Anything else +/// triggers a redirect to `/login`. Player profile pages stay reachable to +/// signed-out viewers so shared links work. +const _publicRoutes = {'/login', '/register'}; + +/// Path prefixes that anonymous viewers may visit even without a session. +/// Player profile pages are intentionally readable so a roster link shared +/// outside the app still works. +const _viewerPrefixes = ['/players/']; + +final appRouterProvider = Provider((ref) { + // GoRouter listens to this notifier and re-evaluates `redirect` whenever + // it fires — we ping it on every auth state change. + final refresh = _AuthRouterRefresh(ref); + ref.onDispose(refresh.dispose); + + return GoRouter( + initialLocation: '/events', + refreshListenable: refresh, + redirect: (context, state) { + final auth = ref.read(authNotifierProvider); + + // Don't redirect while the initial auth check is still loading — + // GoRouter will re-run this once the notifier has data. + if (auth.isLoading || !auth.hasValue) return null; + + final user = auth.value; + final location = state.matchedLocation; + final isPublic = _publicRoutes.contains(location); + final isViewerOk = _viewerPrefixes.any(location.startsWith); + final isAdminRoute = location.startsWith('/admin'); + final isManagerRoute = location == '/manager'; + + if (user == null && !isPublic && !isViewerOk) { + return '/login'; + } + if (user != null && isPublic) { + return '/events'; + } + if (isAdminRoute && !isAdmin(user)) { + return '/events'; + } + if (isManagerRoute) { + // Manager dashboard is reserved for users with the manager role + // (admins also fall through — they have their own panel). + final role = ref.read(currentUserRoleProvider); + if (role != UserRole.manager) { + return '/events'; + } + } + return null; + }, + routes: [ + GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), + GoRoute( + path: '/register', + builder: (context, state) => const RegisterScreen(), + ), + ShellRoute( + builder: (context, state, child) => MainShell(child: child), + routes: [ + GoRoute( + path: '/events', + builder: (context, state) => const EventsScreen(), + routes: [ + GoRoute( + path: ':id', + builder: (context, state) => + EventDetailScreen(eventId: state.pathParameters['id']!), + ), + ], + ), + GoRoute( + path: '/brackets', + builder: (context, state) => const BracketsScreen(), + routes: [ + GoRoute( + path: ':id', + builder: (context, state) => + BracketDetailScreen(bracketId: state.pathParameters['id']!), + ), + ], + ), + GoRoute( + path: '/teams', + builder: (context, state) => const TeamsScreen(), + routes: [ + GoRoute( + path: 'new', + builder: (context, state) => const CreateTeamScreen(), + ), + GoRoute( + path: ':id', + builder: (context, state) => + TeamDetailScreen(teamId: state.pathParameters['id']!), + ), + ], + ), + GoRoute( + path: '/stats', + builder: (context, state) => const StatsScreen(), + ), + GoRoute( + path: '/media', + builder: (context, state) => const MediaScreen(), + ), + GoRoute( + path: '/suggestions', + builder: (context, state) => const SuggestionsScreen(), + ), + GoRoute( + path: '/profile', + builder: (context, state) => const MyProfileScreen(), + ), + GoRoute( + path: '/players/:uid', + builder: (context, state) => + PlayerProfileScreen(uid: state.pathParameters['uid']!), + ), + GoRoute( + path: '/manager', + builder: (context, state) => const ManagerDashboardScreen(), + ), + ], + ), + ShellRoute( + builder: (context, state, child) => AdminShell(child: child), + routes: [ + GoRoute( + path: '/admin/events', + builder: (context, state) => const AdminEventsScreen(), + ), + GoRoute( + path: '/admin/events/new', + builder: (context, state) => const AdminEventFormScreen(), + ), + GoRoute( + path: '/admin/events/:id/edit', + builder: (context, state) => + AdminEventFormScreen(eventId: state.pathParameters['id']), + ), + GoRoute( + path: '/admin/teams', + builder: (context, state) => const AdminTeamsScreen(), + ), + GoRoute( + path: '/admin/teams/new', + builder: (context, state) => const AdminTeamFormScreen(), + ), + GoRoute( + path: '/admin/teams/:id/edit', + builder: (context, state) => + AdminTeamFormScreen(teamId: state.pathParameters['id']), + ), + GoRoute( + path: '/admin/brackets', + builder: (context, state) => const AdminBracketsScreen(), + ), + GoRoute( + path: '/admin/brackets/new', + builder: (context, state) => const AdminBracketFormScreen(), + ), + GoRoute( + path: '/admin/brackets/:id/edit', + builder: (context, state) => + AdminBracketFormScreen(bracketId: state.pathParameters['id']), + ), + GoRoute( + path: '/admin/suggestions', + builder: (context, state) => const AdminSuggestionsScreen(), + ), + GoRoute( + path: '/admin/pending', + builder: (context, state) => const AdminPendingScreen(), + ), + ], + ), + ], + ); +}); + +/// Bridges the Riverpod auth notifier (and the derived role provider) to a +/// [ChangeNotifier] that GoRouter can subscribe to via `refreshListenable`. +/// Sign-in/out and role changes both ping GoRouter to re-run `redirect`. +class _AuthRouterRefresh extends ChangeNotifier { + _AuthRouterRefresh(this._ref) { + _authSub = _ref.listen( + authNotifierProvider, + (prev, next) => notifyListeners(), + fireImmediately: false, + ); + _roleSub = _ref.listen( + currentUserRoleProvider, + (prev, next) { + if (prev != next) notifyListeners(); + }, + fireImmediately: false, + ); + } + + final Ref _ref; + late final ProviderSubscription _authSub; + late final ProviderSubscription _roleSub; + + @override + void dispose() { + _authSub.close(); + _roleSub.close(); + super.dispose(); + } +} diff --git a/lib/core/shell/main_shell.dart b/lib/core/shell/main_shell.dart new file mode 100644 index 0000000..c8ac232 --- /dev/null +++ b/lib/core/shell/main_shell.dart @@ -0,0 +1,176 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../features/auth/application/auth_notifier.dart'; +import '../../features/profile/application/profile_notifier.dart'; +import '../../features/profile/domain/user_profile.dart'; +import '../admin/admin_guard.dart'; + +/// Root shell holding the bottom navigation bar that switches between the +/// six top-level tabs of the app. Labels are uppercase to match the +/// aggressive, jersey-inspired visual language; a thin purple accent line +/// sits above the nav bar for an edgy soccer-badge feel. +/// +/// Admins (per [isAdmin]) see a small gear button overlaid in the top-right +/// of the AppBar band that links into the admin panel. +class MainShell extends ConsumerWidget { + const MainShell({super.key, required this.child}); + + final Widget child; + + static const _tabs = [ + ( + label: 'EVENTS', + icon: Icons.event_outlined, + activeIcon: Icons.event, + path: '/events', + ), + ( + label: 'BRACKETS', + icon: Icons.account_tree_outlined, + activeIcon: Icons.account_tree, + path: '/brackets', + ), + ( + label: 'TEAMS', + icon: Icons.groups_outlined, + activeIcon: Icons.groups, + path: '/teams', + ), + ( + label: 'STATS', + icon: Icons.bar_chart_outlined, + activeIcon: Icons.bar_chart, + path: '/stats', + ), + ( + label: 'MEDIA', + icon: Icons.play_circle_outline, + activeIcon: Icons.play_circle, + path: '/media', + ), + ( + label: 'SUGGEST', + icon: Icons.lightbulb_outline, + activeIcon: Icons.lightbulb, + path: '/suggestions', + ), + ]; + + int _currentIndex(BuildContext context) { + final location = GoRouterState.of(context).uri.path; + final idx = _tabs.indexWhere((t) => location.startsWith(t.path)); + return idx < 0 ? 0 : idx; + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentIndex = _currentIndex(context); + final user = ref.watch(authNotifierProvider).valueOrNull; + final showAdmin = isAdmin(user); + final role = ref.watch(currentUserRoleProvider); + final showProfile = user != null; + final showManager = role == UserRole.manager; + + return Scaffold( + body: Stack( + children: [ + Positioned.fill(child: child), + Positioned( + top: 0, + right: 0, + child: SafeArea( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (showManager) + Material( + color: Colors.transparent, + child: IconButton( + icon: const Icon(Icons.shield_outlined), + tooltip: 'Manager dashboard', + onPressed: () => context.go('/manager'), + ), + ), + if (showAdmin) + Material( + color: Colors.transparent, + child: IconButton( + icon: const Icon(Icons.settings_outlined), + tooltip: 'Admin panel', + onPressed: () => context.go('/admin/events'), + ), + ), + if (showProfile) + Material( + color: Colors.transparent, + child: IconButton( + icon: const Icon(Icons.person_outline), + tooltip: 'My profile', + onPressed: () => context.go('/profile'), + ), + ), + Material( + color: Colors.transparent, + child: PopupMenuButton<_UserMenuAction>( + icon: const Icon(Icons.account_circle_outlined), + tooltip: 'Account', + onSelected: (action) async { + if (action == _UserMenuAction.signOut) { + await ref.read(authNotifierProvider.notifier).signOut(); + } + }, + itemBuilder: (context) => >[ + PopupMenuItem<_UserMenuAction>( + enabled: false, + child: Text( + user?.email ?? '', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const PopupMenuDivider(), + const PopupMenuItem<_UserMenuAction>( + value: _UserMenuAction.signOut, + child: Row( + children: [ + Icon(Icons.logout, size: 18), + SizedBox(width: 8), + Text('Sign out'), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + bottomNavigationBar: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Sharp purple accent line above the nav bar for an edgy look. + Container(height: 1, color: const Color(0xFF8B30C8)), + NavigationBar( + selectedIndex: currentIndex, + onDestinationSelected: (i) => context.go(_tabs[i].path), + destinations: _tabs + .map( + (t) => NavigationDestination( + icon: Icon(t.icon), + selectedIcon: Icon(t.activeIcon), + label: t.label, + ), + ) + .toList(), + ), + ], + ), + ); + } +} + +enum _UserMenuAction { signOut } diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..331043b --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; + +/// Aggressive, high-contrast Shadow Oak theme. +/// +/// Visual direction: urban soccer / techy / grunge badge style. +/// Sharp corners, deep purple primary, near-black surfaces, heavy weight +/// type with negative tracking on headings and uppercase tracking on labels. +class AppTheme { + AppTheme._(); + + static const purple = Color(0xFF8B30C8); + static const purpleLight = Color(0xFFBF77F6); + static const black = Color(0xFF0A0A0A); + static const surface = Color(0xFF141414); + static const surfaceVariant = Color(0xFF1E1E1E); + + static final dark = ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: black, + colorScheme: const ColorScheme.dark( + primary: purple, + onPrimary: Colors.white, + primaryContainer: Color(0xFF3D1260), + onPrimaryContainer: purpleLight, + secondary: purpleLight, + onSecondary: Colors.black, + secondaryContainer: Color(0xFF2A1045), + onSecondaryContainer: purpleLight, + surface: surface, + onSurface: Colors.white, + surfaceContainerHighest: surfaceVariant, + onSurfaceVariant: Color(0xFFBBBBBB), + outline: Color(0xFF444444), + outlineVariant: Color(0xFF2A2A2A), + error: Color(0xFFF44336), + onError: Colors.white, + ), + // Sharp shapes throughout + cardTheme: CardThemeData( + color: surface, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: const BorderSide(color: Color(0xFF2A2A2A)), + ), + margin: EdgeInsets.zero, + ), + appBarTheme: const AppBarTheme( + backgroundColor: Color(0xFF0A0A0A), + foregroundColor: Colors.white, + elevation: 0, + centerTitle: false, + titleTextStyle: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w900, + letterSpacing: 1.5, + ), + surfaceTintColor: Colors.transparent, + ), + navigationBarTheme: NavigationBarThemeData( + backgroundColor: surface, + indicatorColor: const Color(0xFF3D1260), + labelTextStyle: WidgetStateProperty.resolveWith((states) { + final selected = states.contains(WidgetState.selected); + return TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + color: selected ? purpleLight : const Color(0xFF888888), + ); + }), + iconTheme: WidgetStateProperty.resolveWith((states) { + final selected = states.contains(WidgetState.selected); + return IconThemeData( + color: selected ? purpleLight : const Color(0xFF888888), + size: 22, + ); + }), + height: 64, + elevation: 0, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + backgroundColor: purple, + foregroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4)), + ), + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + ), + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: purpleLight, + side: const BorderSide(color: Color(0xFF8B30C8)), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4)), + ), + textStyle: const TextStyle( + fontWeight: FontWeight.w700, + letterSpacing: 1.0, + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 20), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: surfaceVariant, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: const BorderSide(color: Color(0xFF444444)), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: const BorderSide(color: Color(0xFF555555)), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: const BorderSide(color: purple, width: 2), + ), + labelStyle: const TextStyle(color: Color(0xFF888888)), + hintStyle: const TextStyle(color: Color(0xFF555555)), + ), + chipTheme: ChipThemeData( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), + side: BorderSide.none, + ), + dividerTheme: const DividerThemeData( + color: Color(0xFF1E1E1E), + thickness: 1, + ), + textTheme: const TextTheme( + displayLarge: TextStyle(fontWeight: FontWeight.w900, letterSpacing: -1.5), + displayMedium: TextStyle(fontWeight: FontWeight.w900, letterSpacing: -1.0), + displaySmall: TextStyle(fontWeight: FontWeight.w800, letterSpacing: -0.5), + headlineLarge: TextStyle(fontWeight: FontWeight.w800), + headlineMedium: TextStyle(fontWeight: FontWeight.w800), + headlineSmall: TextStyle(fontWeight: FontWeight.w700), + titleLarge: TextStyle(fontWeight: FontWeight.w700), + titleMedium: TextStyle(fontWeight: FontWeight.w600, letterSpacing: 0.5), + titleSmall: TextStyle(fontWeight: FontWeight.w600, letterSpacing: 0.8), + labelLarge: TextStyle(fontWeight: FontWeight.w700, letterSpacing: 1.2), + labelMedium: TextStyle(fontWeight: FontWeight.w600, letterSpacing: 0.8), + labelSmall: TextStyle(fontWeight: FontWeight.w600, letterSpacing: 1.0), + ), + ); + + static final light = dark; // dark-only app for now +} diff --git a/lib/features/admin/application/admin_brackets_notifier.dart b/lib/features/admin/application/admin_brackets_notifier.dart new file mode 100644 index 0000000..d38f926 --- /dev/null +++ b/lib/features/admin/application/admin_brackets_notifier.dart @@ -0,0 +1,57 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../brackets/domain/bracket.dart'; +import '../../brackets/infrastructure/brackets_repository.dart'; + +part 'admin_brackets_notifier.g.dart'; + +/// Live Firestore-backed stream of every bracket, used by the admin panel. +@riverpod +Stream> adminBracketsStream(AdminBracketsStreamRef ref) { + final repo = ref.watch(bracketsRepositoryProvider); + return repo.watchBrackets(); +} + +/// Imperative wrapper around the brackets repository write methods. +@riverpod +class AdminBracketsNotifier extends _$AdminBracketsNotifier { + @override + Future build() async {} + + Future create(Bracket bracket) async { + final repo = ref.read(bracketsRepositoryProvider); + state = const AsyncLoading(); + try { + final id = await repo.createBracket(bracket); + state = const AsyncData(null); + return id; + } catch (e, st) { + state = AsyncError(e, st); + rethrow; + } + } + + Future save(Bracket bracket) async { + final repo = ref.read(bracketsRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard(() => repo.updateBracket(bracket)); + } + + Future delete(String id) async { + final repo = ref.read(bracketsRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard(() => repo.deleteBracket(id)); + } + + Future updateMatch( + String bracketId, + String roundLabel, + BracketMatch match, + ) async { + final repo = ref.read(bracketsRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => repo.updateMatch(bracketId, roundLabel, match), + ); + } +} diff --git a/lib/features/admin/application/admin_brackets_notifier.g.dart b/lib/features/admin/application/admin_brackets_notifier.g.dart new file mode 100644 index 0000000..7a1f3da --- /dev/null +++ b/lib/features/admin/application/admin_brackets_notifier.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'admin_brackets_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$adminBracketsStreamHash() => + r'2a76ca85dc76fc7514b7b9ae17a5610f1c1760d9'; + +/// Live Firestore-backed stream of every bracket, used by the admin panel. +/// +/// Copied from [adminBracketsStream]. +@ProviderFor(adminBracketsStream) +final adminBracketsStreamProvider = + AutoDisposeStreamProvider>.internal( + adminBracketsStream, + name: r'adminBracketsStreamProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$adminBracketsStreamHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AdminBracketsStreamRef = AutoDisposeStreamProviderRef>; +String _$adminBracketsNotifierHash() => + r'ac2ba11f3c44e7feccf440538249e078c9a55031'; + +/// Imperative wrapper around the brackets repository write methods. +/// +/// Copied from [AdminBracketsNotifier]. +@ProviderFor(AdminBracketsNotifier) +final adminBracketsNotifierProvider = + AutoDisposeAsyncNotifierProvider.internal( + AdminBracketsNotifier.new, + name: r'adminBracketsNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$adminBracketsNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$AdminBracketsNotifier = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/admin/application/admin_events_notifier.dart b/lib/features/admin/application/admin_events_notifier.dart new file mode 100644 index 0000000..8dc08ad --- /dev/null +++ b/lib/features/admin/application/admin_events_notifier.dart @@ -0,0 +1,49 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../events/domain/event.dart'; +import '../../events/infrastructure/events_repository.dart'; + +part 'admin_events_notifier.g.dart'; + +/// Live Firestore-backed stream of every event in the system, used by the +/// admin panel. The public-facing [eventsStreamProvider] still emits mocked +/// data; admins read straight through to the real collection. +@riverpod +Stream> adminEventsStream(AdminEventsStreamRef ref) { + final repo = ref.watch(eventsRepositoryProvider); + return repo.watchEvents(); +} + +/// Imperative wrapper around the events repository write methods. The notifier +/// is `AsyncValue`-shaped so screens can wire it up the same way as the +/// existing auth/suggestions notifiers. +@riverpod +class AdminEventsNotifier extends _$AdminEventsNotifier { + @override + Future build() async {} + + Future create(Event event) async { + final repo = ref.read(eventsRepositoryProvider); + state = const AsyncLoading(); + try { + final id = await repo.createEvent(event); + state = const AsyncData(null); + return id; + } catch (e, st) { + state = AsyncError(e, st); + rethrow; + } + } + + Future save(Event event) async { + final repo = ref.read(eventsRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard(() => repo.updateEvent(event)); + } + + Future delete(String id) async { + final repo = ref.read(eventsRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard(() => repo.deleteEvent(id)); + } +} diff --git a/lib/features/admin/application/admin_events_notifier.g.dart b/lib/features/admin/application/admin_events_notifier.g.dart new file mode 100644 index 0000000..078016f --- /dev/null +++ b/lib/features/admin/application/admin_events_notifier.g.dart @@ -0,0 +1,53 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'admin_events_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$adminEventsStreamHash() => r'33d6cd9ec02f788540270db08f0933e9c46c72e8'; + +/// Live Firestore-backed stream of every event in the system, used by the +/// admin panel. The public-facing [eventsStreamProvider] still emits mocked +/// data; admins read straight through to the real collection. +/// +/// Copied from [adminEventsStream]. +@ProviderFor(adminEventsStream) +final adminEventsStreamProvider = + AutoDisposeStreamProvider>.internal( + adminEventsStream, + name: r'adminEventsStreamProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$adminEventsStreamHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AdminEventsStreamRef = AutoDisposeStreamProviderRef>; +String _$adminEventsNotifierHash() => + r'd39031c4b14120bba5d4ea0baeed2661eb336ec0'; + +/// Imperative wrapper around the events repository write methods. The notifier +/// is `AsyncValue`-shaped so screens can wire it up the same way as the +/// existing auth/suggestions notifiers. +/// +/// Copied from [AdminEventsNotifier]. +@ProviderFor(AdminEventsNotifier) +final adminEventsNotifierProvider = + AutoDisposeAsyncNotifierProvider.internal( + AdminEventsNotifier.new, + name: r'adminEventsNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$adminEventsNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$AdminEventsNotifier = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/admin/application/admin_suggestions_notifier.dart b/lib/features/admin/application/admin_suggestions_notifier.dart new file mode 100644 index 0000000..0f0f922 --- /dev/null +++ b/lib/features/admin/application/admin_suggestions_notifier.dart @@ -0,0 +1,35 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../suggestions/domain/suggestion.dart'; +import '../../suggestions/infrastructure/suggestions_repository.dart'; + +part 'admin_suggestions_notifier.g.dart'; + +/// Live Firestore-backed stream of every suggestion, newest first, for the +/// admin review dashboard. +@riverpod +Stream> adminSuggestionsStream( + AdminSuggestionsStreamRef ref, +) { + final repo = ref.watch(suggestionsRepositoryProvider); + return repo.watchAllSuggestions(); +} + +/// Imperative wrapper around the suggestion write methods. +@riverpod +class AdminSuggestionsNotifier extends _$AdminSuggestionsNotifier { + @override + Future build() async {} + + Future updateStatus(String id, SuggestionStatus status) async { + final repo = ref.read(suggestionsRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard(() => repo.updateStatus(id, status)); + } + + Future delete(String id) async { + final repo = ref.read(suggestionsRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard(() => repo.deleteSuggestion(id)); + } +} diff --git a/lib/features/admin/application/admin_suggestions_notifier.g.dart b/lib/features/admin/application/admin_suggestions_notifier.g.dart new file mode 100644 index 0000000..0defe19 --- /dev/null +++ b/lib/features/admin/application/admin_suggestions_notifier.g.dart @@ -0,0 +1,52 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'admin_suggestions_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$adminSuggestionsStreamHash() => + r'e87ca116c64b03bf5e62df4c390ff5c3dcfb4e0a'; + +/// Live Firestore-backed stream of every suggestion, newest first, for the +/// admin review dashboard. +/// +/// Copied from [adminSuggestionsStream]. +@ProviderFor(adminSuggestionsStream) +final adminSuggestionsStreamProvider = + AutoDisposeStreamProvider>.internal( + adminSuggestionsStream, + name: r'adminSuggestionsStreamProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$adminSuggestionsStreamHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AdminSuggestionsStreamRef = + AutoDisposeStreamProviderRef>; +String _$adminSuggestionsNotifierHash() => + r'fd85d538be1e2d9abad02812d9c964c2df2b547a'; + +/// Imperative wrapper around the suggestion write methods. +/// +/// Copied from [AdminSuggestionsNotifier]. +@ProviderFor(AdminSuggestionsNotifier) +final adminSuggestionsNotifierProvider = + AutoDisposeAsyncNotifierProvider.internal( + AdminSuggestionsNotifier.new, + name: r'adminSuggestionsNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$adminSuggestionsNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$AdminSuggestionsNotifier = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/admin/application/admin_teams_notifier.dart b/lib/features/admin/application/admin_teams_notifier.dart new file mode 100644 index 0000000..46a40ba --- /dev/null +++ b/lib/features/admin/application/admin_teams_notifier.dart @@ -0,0 +1,46 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../teams/domain/team.dart'; +import '../../teams/infrastructure/teams_repository.dart'; + +part 'admin_teams_notifier.g.dart'; + +/// Live Firestore-backed stream of every team (including pending and +/// rejected), used by the admin panel. +@riverpod +Stream> adminTeamsStream(AdminTeamsStreamRef ref) { + final repo = ref.watch(teamsRepositoryProvider); + return repo.adminWatchAllTeams(); +} + +/// Imperative wrapper around the teams repository write methods. +@riverpod +class AdminTeamsNotifier extends _$AdminTeamsNotifier { + @override + Future build() async {} + + Future create(Team team) async { + final repo = ref.read(teamsRepositoryProvider); + state = const AsyncLoading(); + try { + final id = await repo.createTeam(team); + state = const AsyncData(null); + return id; + } catch (e, st) { + state = AsyncError(e, st); + rethrow; + } + } + + Future save(Team team) async { + final repo = ref.read(teamsRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard(() => repo.updateTeam(team)); + } + + Future delete(String id) async { + final repo = ref.read(teamsRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard(() => repo.deleteTeam(id)); + } +} diff --git a/lib/features/admin/application/admin_teams_notifier.g.dart b/lib/features/admin/application/admin_teams_notifier.g.dart new file mode 100644 index 0000000..d5bfbc9 --- /dev/null +++ b/lib/features/admin/application/admin_teams_notifier.g.dart @@ -0,0 +1,49 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'admin_teams_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$adminTeamsStreamHash() => r'f392e2c9de281c80912d4fccfaf56c0cbe8ef880'; + +/// Live Firestore-backed stream of every team (including pending and +/// rejected), used by the admin panel. +/// +/// Copied from [adminTeamsStream]. +@ProviderFor(adminTeamsStream) +final adminTeamsStreamProvider = AutoDisposeStreamProvider>.internal( + adminTeamsStream, + name: r'adminTeamsStreamProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$adminTeamsStreamHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef AdminTeamsStreamRef = AutoDisposeStreamProviderRef>; +String _$adminTeamsNotifierHash() => + r'1f5febaa0f2eb35596538db76896c96dd240a1d8'; + +/// Imperative wrapper around the teams repository write methods. +/// +/// Copied from [AdminTeamsNotifier]. +@ProviderFor(AdminTeamsNotifier) +final adminTeamsNotifierProvider = + AutoDisposeAsyncNotifierProvider.internal( + AdminTeamsNotifier.new, + name: r'adminTeamsNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$adminTeamsNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$AdminTeamsNotifier = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/admin/presentation/admin_shell.dart b/lib/features/admin/presentation/admin_shell.dart new file mode 100644 index 0000000..71c6091 --- /dev/null +++ b/lib/features/admin/presentation/admin_shell.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +/// Shell for the admin panel. On screens 640px wide and below it shows a +/// bottom NavigationBar; on wider viewports it switches to a NavigationRail +/// so admins can sweep through tabs with a single click on web/desktop. +class AdminShell extends StatelessWidget { + const AdminShell({super.key, required this.child}); + + final Widget child; + + static const _tabs = <_AdminTab>[ + _AdminTab( + label: 'EVENTS', + icon: Icons.event_outlined, + activeIcon: Icons.event, + path: '/admin/events', + ), + _AdminTab( + label: 'TEAMS', + icon: Icons.groups_outlined, + activeIcon: Icons.groups, + path: '/admin/teams', + ), + _AdminTab( + label: 'BRACKETS', + icon: Icons.account_tree_outlined, + activeIcon: Icons.account_tree, + path: '/admin/brackets', + ), + _AdminTab( + label: 'IDEAS', + icon: Icons.lightbulb_outline, + activeIcon: Icons.lightbulb, + path: '/admin/suggestions', + ), + _AdminTab( + label: 'PENDING', + icon: Icons.pending_actions_outlined, + activeIcon: Icons.pending_actions, + path: '/admin/pending', + ), + ]; + + int _currentIndex(BuildContext context) { + final location = GoRouterState.of(context).uri.path; + final idx = _tabs.indexWhere((t) => location.startsWith(t.path)); + return idx < 0 ? 0 : idx; + } + + @override + Widget build(BuildContext context) { + final currentIndex = _currentIndex(context); + return Scaffold( + appBar: AppBar( + title: const Text('ADMIN'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + tooltip: 'Back to app', + onPressed: () => context.go('/events'), + ), + ), + body: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 640; + if (isWide) { + return Row( + children: [ + NavigationRail( + selectedIndex: currentIndex, + onDestinationSelected: (i) => context.go(_tabs[i].path), + labelType: NavigationRailLabelType.all, + destinations: _tabs + .map( + (t) => NavigationRailDestination( + icon: Icon(t.icon), + selectedIcon: Icon(t.activeIcon), + label: Text(t.label), + ), + ) + .toList(), + ), + const VerticalDivider(width: 1), + Expanded(child: child), + ], + ); + } + return child; + }, + ), + bottomNavigationBar: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 640; + if (isWide) return const SizedBox.shrink(); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container(height: 1, color: const Color(0xFF8B30C8)), + NavigationBar( + selectedIndex: currentIndex, + onDestinationSelected: (i) => context.go(_tabs[i].path), + destinations: _tabs + .map( + (t) => NavigationDestination( + icon: Icon(t.icon), + selectedIcon: Icon(t.activeIcon), + label: t.label, + ), + ) + .toList(), + ), + ], + ); + }, + ), + ); + } +} + +class _AdminTab { + const _AdminTab({ + required this.label, + required this.icon, + required this.activeIcon, + required this.path, + }); + + final String label; + final IconData icon; + final IconData activeIcon; + final String path; +} diff --git a/lib/features/admin/presentation/brackets/admin_bracket_form_screen.dart b/lib/features/admin/presentation/brackets/admin_bracket_form_screen.dart new file mode 100644 index 0000000..33250fa --- /dev/null +++ b/lib/features/admin/presentation/brackets/admin_bracket_form_screen.dart @@ -0,0 +1,791 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../brackets/domain/bracket.dart'; +import '../../../events/domain/event.dart'; +import '../../../teams/domain/team.dart'; +import '../../../teams/infrastructure/teams_repository.dart'; +import '../../application/admin_brackets_notifier.dart'; +import '../../application/admin_events_notifier.dart'; + +/// Form for creating or editing a tournament bracket. +/// +/// The shape (round count + matches per round) is set up front when creating. +/// After creation — or when editing — admins can adjust team labels, set +/// scores, change match status, and pick a winner per match. +class AdminBracketFormScreen extends ConsumerStatefulWidget { + const AdminBracketFormScreen({super.key, this.bracketId}); + + final String? bracketId; + + bool get isEdit => bracketId != null; + + @override + ConsumerState createState() => + _AdminBracketFormScreenState(); +} + +class _AdminBracketFormScreenState + extends ConsumerState { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + String? _eventId; + DateTime _createdAt = DateTime.now(); + + List<_RoundDraft> _rounds = <_RoundDraft>[]; + + // Setup-mode controls (visible when creating a brand-new bracket) + int _setupRounds = 3; + int _setupTeams = 8; + + bool _hydrated = false; + bool _submitting = false; + + @override + void initState() { + super.initState(); + if (!widget.isEdit) { + _hydrated = true; + _generateRounds(rounds: _setupRounds, teams: _setupTeams); + } + } + + @override + void dispose() { + _nameCtrl.dispose(); + for (final r in _rounds) { + r.dispose(); + } + super.dispose(); + } + + void _hydrateFrom(Bracket bracket) { + if (_hydrated) return; + _nameCtrl.text = bracket.name; + _eventId = bracket.eventId.isEmpty ? null : bracket.eventId; + _createdAt = bracket.createdAt; + _rounds = bracket.rounds.map(_RoundDraft.fromRound).toList(); + _hydrated = true; + } + + void _generateRounds({required int rounds, required int teams}) { + // For a single-elimination shape, round 1 holds teams/2 matches, round 2 + // holds teams/4, etc. We round up to handle odd team counts gracefully. + for (final r in _rounds) { + r.dispose(); + } + _rounds = <_RoundDraft>[]; + var matchesInRound = (teams / 2).ceil(); + for (var i = 0; i < rounds; i++) { + _rounds.add( + _RoundDraft( + roundNumber: i + 1, + label: _defaultRoundLabel(i, rounds), + matches: List.generate( + matchesInRound < 1 ? 1 : matchesInRound, + (m) => _MatchDraft.empty(id: 'r${i + 1}_m${m + 1}'), + ), + ), + ); + matchesInRound = (matchesInRound / 2).ceil(); + } + setState(() {}); + } + + String _defaultRoundLabel(int index, int total) { + final distanceFromEnd = total - 1 - index; + switch (distanceFromEnd) { + case 0: + return 'Final'; + case 1: + return 'Semifinals'; + case 2: + return 'Quarterfinals'; + case 3: + return 'Round of 16'; + default: + return 'Round ${index + 1}'; + } + } + + Future _randomizeTeams() async { + final messenger = ScaffoldMessenger.of(context); + final List teams = await ref + .read(teamsRepositoryProvider) + .watchTeams() + .first; + if (!mounted) return; + if (teams.length < 2) { + messenger.showSnackBar( + const SnackBar( + content: Text('Need at least 2 teams in Firestore to randomize.'), + ), + ); + return; + } + if (_rounds.isEmpty || _rounds.first.matches.isEmpty) { + messenger.showSnackBar( + const SnackBar(content: Text('No round-1 matches to fill.')), + ); + return; + } + + final shuffled = List.of(teams)..shuffle(); + final round1 = _rounds.first; + + setState(() { + var teamIdx = 0; + for (final match in round1.matches) { + if (teamIdx < shuffled.length) { + final a = shuffled[teamIdx++]; + match.teamANameCtrl.text = a.name; + match.teamAId = a.id; + } else { + match.teamANameCtrl.text = ''; + match.teamAId = null; + } + if (teamIdx < shuffled.length) { + final b = shuffled[teamIdx++]; + match.teamBNameCtrl.text = b.name; + match.teamBId = b.id; + } else { + match.teamBNameCtrl.text = ''; + match.teamBId = null; + } + } + }); + + messenger.showSnackBar( + SnackBar( + content: Text('Randomized ${shuffled.length} teams into round 1.'), + ), + ); + } + + Future _submit() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + if (_eventId == null || _eventId!.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Pick an event for this bracket.')), + ); + return; + } + final id = widget.bracketId ?? ''; + final bracket = Bracket( + id: id, + eventId: _eventId!, + name: _nameCtrl.text.trim(), + createdAt: _createdAt, + rounds: _rounds.map((r) => r.toRound()).toList(growable: false), + ); + + setState(() => _submitting = true); + try { + if (widget.isEdit) { + await ref.read(adminBracketsNotifierProvider.notifier).save(bracket); + } else { + await ref.read(adminBracketsNotifierProvider.notifier).create(bracket); + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.isEdit ? 'Bracket updated' : 'Bracket created'), + ), + ); + context.go('/admin/brackets'); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Save failed: $e'))); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + if (widget.isEdit && !_hydrated) { + final bracketsAsync = ref.watch(adminBracketsStreamProvider); + final brackets = bracketsAsync.valueOrNull; + if (brackets != null) { + Bracket? match; + for (final b in brackets) { + if (b.id == widget.bracketId) { + match = b; + break; + } + } + if (match != null) { + final found = match; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _hydrateFrom(found)); + }); + } + } + } + + final eventsAsync = ref.watch(adminEventsStreamProvider); + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: Text(widget.isEdit ? 'EDIT BRACKET' : 'NEW BRACKET'), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => context.go('/admin/brackets'), + ), + ), + body: SafeArea( + child: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration(labelText: 'Bracket name'), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 12), + eventsAsync.when( + loading: () => const LinearProgressIndicator(), + error: (e, _) => Text('Could not load events: $e'), + data: (events) => _EventPicker( + events: events, + selected: _eventId, + onChanged: (id) => setState(() => _eventId = id), + ), + ), + if (!widget.isEdit) ...[ + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: _submitting ? null : _randomizeTeams, + icon: const Text('🎲', style: TextStyle(fontSize: 16)), + label: const Text('RANDOMIZE TEAMS'), + ), + ), + ], + const SizedBox(height: 20), + if (!widget.isEdit) ...[ + Text( + 'BRACKET SHAPE', + style: theme.textTheme.labelLarge?.copyWith( + letterSpacing: 1.5, + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: _NumberStepper( + label: 'Rounds', + value: _setupRounds, + min: 1, + max: 6, + onChanged: (v) => setState(() { + _setupRounds = v; + _generateRounds(rounds: v, teams: _setupTeams); + }), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _NumberStepper( + label: 'Teams in round 1', + value: _setupTeams, + min: 2, + max: 32, + step: 2, + onChanged: (v) => setState(() { + _setupTeams = v; + _generateRounds(rounds: _setupRounds, teams: v); + }), + ), + ), + ], + ), + const SizedBox(height: 16), + ], + ..._rounds.map( + (round) => _RoundEditor( + round: round, + onChanged: () => setState(() {}), + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _submitting ? null : _submit, + icon: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(widget.isEdit ? 'SAVE CHANGES' : 'CREATE BRACKET'), + ), + ], + ), + ), + ), + ); + } +} + +class _EventPicker extends StatelessWidget { + const _EventPicker({ + required this.events, + required this.selected, + required this.onChanged, + }); + + final List events; + final String? selected; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final items = >[ + const DropdownMenuItem( + value: null, + child: Text('— select event —'), + ), + ...events.map( + (e) => DropdownMenuItem( + value: e.id, + child: Text(e.title, overflow: TextOverflow.ellipsis), + ), + ), + ]; + + final currentValue = events.any((e) => e.id == selected) ? selected : null; + + return DropdownButtonFormField( + initialValue: currentValue, + items: items, + onChanged: onChanged, + decoration: const InputDecoration(labelText: 'Event'), + ); + } +} + +class _NumberStepper extends StatelessWidget { + const _NumberStepper({ + required this.label, + required this.value, + required this.min, + required this.max, + required this.onChanged, + this.step = 1, + }); + + final String label; + final int value; + final int min; + final int max; + final int step; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + Row( + children: [ + IconButton( + icon: const Icon(Icons.remove), + onPressed: value - step >= min + ? () => onChanged(value - step) + : null, + ), + Expanded( + child: Text( + '$value', + textAlign: TextAlign.center, + style: theme.textTheme.titleLarge, + ), + ), + IconButton( + icon: const Icon(Icons.add), + onPressed: value + step <= max + ? () => onChanged(value + step) + : null, + ), + ], + ), + ], + ), + ); + } +} + +class _RoundEditor extends StatelessWidget { + const _RoundEditor({required this.round, required this.onChanged}); + + final _RoundDraft round; + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: round.labelCtrl, + decoration: InputDecoration( + labelText: 'Round ${round.roundNumber} label', + hintText: 'e.g. Quarterfinals', + ), + ), + const SizedBox(height: 12), + for (var i = 0; i < round.matches.length; i++) + _MatchEditor( + index: i, + match: round.matches[i], + onChanged: onChanged, + ), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () { + round.matches.add( + _MatchDraft.empty( + id: 'r${round.roundNumber}_m${round.matches.length + 1}', + ), + ); + onChanged(); + }, + icon: const Icon(Icons.add, size: 18), + label: Text('Add match', style: theme.textTheme.labelMedium), + ), + ), + ], + ), + ), + ); + } +} + +class _MatchEditor extends StatelessWidget { + const _MatchEditor({ + required this.index, + required this.match, + required this.onChanged, + }); + + final int index; + final _MatchDraft match; + final VoidCallback onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'MATCH ${index + 1}', + style: theme.textTheme.labelSmall?.copyWith( + letterSpacing: 1.2, + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const Spacer(), + DropdownButton( + value: match.status, + onChanged: (v) { + if (v == null) return; + match.status = v; + onChanged(); + }, + items: MatchStatus.values + .map( + (s) => DropdownMenuItem( + value: s, + child: Text(_statusLabel(s)), + ), + ) + .toList(), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: match.teamANameCtrl, + decoration: const InputDecoration(labelText: 'Team A'), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 72, + child: TextField( + controller: match.scoreACtrl, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + decoration: const InputDecoration(labelText: 'Score'), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextField( + controller: match.teamBNameCtrl, + decoration: const InputDecoration(labelText: 'Team B'), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 72, + child: TextField( + controller: match.scoreBCtrl, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + decoration: const InputDecoration(labelText: 'Score'), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Text( + 'Winner: ', + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + Expanded( + child: SegmentedButton<_WinnerSelection>( + segments: const >[ + ButtonSegment( + value: _WinnerSelection.none, + label: Text('TBD'), + ), + ButtonSegment( + value: _WinnerSelection.teamA, + label: Text('A'), + ), + ButtonSegment( + value: _WinnerSelection.teamB, + label: Text('B'), + ), + ], + selected: <_WinnerSelection>{match.winner}, + onSelectionChanged: (set) { + match.winner = set.first; + onChanged(); + }, + ), + ), + ], + ), + ], + ), + ); + } + + String _statusLabel(MatchStatus s) { + switch (s) { + case MatchStatus.scheduled: + return 'Scheduled'; + case MatchStatus.inProgress: + return 'In progress'; + case MatchStatus.completed: + return 'Completed'; + } + } +} + +enum _WinnerSelection { none, teamA, teamB } + +class _RoundDraft { + _RoundDraft({ + required this.roundNumber, + required String label, + required this.matches, + }) : labelCtrl = TextEditingController(text: label); + + factory _RoundDraft.fromRound(BracketRound round) { + return _RoundDraft( + roundNumber: round.roundNumber, + label: round.label, + matches: round.matches.map(_MatchDraft.fromMatch).toList(), + ); + } + + final int roundNumber; + final TextEditingController labelCtrl; + final List<_MatchDraft> matches; + + void dispose() { + labelCtrl.dispose(); + for (final m in matches) { + m.dispose(); + } + } + + BracketRound toRound() { + return BracketRound( + roundNumber: roundNumber, + label: labelCtrl.text.trim().isEmpty + ? 'Round $roundNumber' + : labelCtrl.text.trim(), + matches: matches.map((m) => m.toMatch()).toList(growable: false), + ); + } +} + +class _MatchDraft { + _MatchDraft({ + required this.id, + required String teamA, + required String teamB, + required int? scoreA, + required int? scoreB, + required this.status, + required this.winner, + this.teamAId, + this.teamBId, + }) : teamANameCtrl = TextEditingController(text: teamA), + teamBNameCtrl = TextEditingController(text: teamB), + scoreACtrl = TextEditingController( + text: scoreA == null ? '' : '$scoreA', + ), + scoreBCtrl = TextEditingController( + text: scoreB == null ? '' : '$scoreB', + ); + + factory _MatchDraft.empty({required String id}) { + return _MatchDraft( + id: id, + teamA: '', + teamB: '', + scoreA: null, + scoreB: null, + status: MatchStatus.scheduled, + winner: _WinnerSelection.none, + ); + } + + factory _MatchDraft.fromMatch(BracketMatch match) { + final winner = match.winnerId == null + ? _WinnerSelection.none + : match.isTeamAWinner + ? _WinnerSelection.teamA + : match.isTeamBWinner + ? _WinnerSelection.teamB + : _WinnerSelection.none; + return _MatchDraft( + id: match.id, + teamA: match.teamA?.name ?? '', + teamB: match.teamB?.name ?? '', + scoreA: match.scoreA, + scoreB: match.scoreB, + status: match.status, + winner: winner, + teamAId: match.teamA?.id, + teamBId: match.teamB?.id, + ); + } + + final String id; + final TextEditingController teamANameCtrl; + final TextEditingController teamBNameCtrl; + final TextEditingController scoreACtrl; + final TextEditingController scoreBCtrl; + MatchStatus status; + _WinnerSelection winner; + String? teamAId; + String? teamBId; + + void dispose() { + teamANameCtrl.dispose(); + teamBNameCtrl.dispose(); + scoreACtrl.dispose(); + scoreBCtrl.dispose(); + } + + BracketMatch toMatch() { + final aName = teamANameCtrl.text.trim(); + final bName = teamBNameCtrl.text.trim(); + final teamA = aName.isEmpty + ? null + : BracketTeam(id: teamAId ?? _slug(aName), name: aName); + final teamB = bName.isEmpty + ? null + : BracketTeam(id: teamBId ?? _slug(bName), name: bName); + + String? winnerId; + switch (winner) { + case _WinnerSelection.none: + winnerId = null; + break; + case _WinnerSelection.teamA: + winnerId = teamA?.id; + break; + case _WinnerSelection.teamB: + winnerId = teamB?.id; + break; + } + + return BracketMatch( + id: id, + teamA: teamA, + teamB: teamB, + scoreA: int.tryParse(scoreACtrl.text.trim()), + scoreB: int.tryParse(scoreBCtrl.text.trim()), + status: status, + winnerId: winnerId, + ); + } + + static String _slug(String input) { + final lower = input.toLowerCase(); + final cleaned = lower + .replaceAll(RegExp(r'[^a-z0-9]+'), '_') + .replaceAll(RegExp(r'^_|_$'), ''); + return cleaned.isEmpty ? 'team' : cleaned; + } +} diff --git a/lib/features/admin/presentation/brackets/admin_brackets_screen.dart b/lib/features/admin/presentation/brackets/admin_brackets_screen.dart new file mode 100644 index 0000000..a6dc9dc --- /dev/null +++ b/lib/features/admin/presentation/brackets/admin_brackets_screen.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../../brackets/domain/bracket.dart'; +import '../../application/admin_brackets_notifier.dart'; + +class AdminBracketsScreen extends ConsumerWidget { + const AdminBracketsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bracketsAsync = ref.watch(adminBracketsStreamProvider); + final theme = Theme.of(context); + + return Scaffold( + body: bracketsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: theme.colorScheme.error, + ), + const SizedBox(height: 12), + Text( + 'Could not load brackets', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + '$err', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + data: (brackets) { + if (brackets.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.account_tree_outlined, + size: 64, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No brackets yet', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Tap the + button to create your first bracket.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + itemCount: brackets.length, + itemBuilder: (context, i) => _BracketRow(bracket: brackets[i]), + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => context.go('/admin/brackets/new'), + icon: const Icon(Icons.add), + label: const Text('NEW BRACKET'), + ), + ); + } +} + +class _BracketRow extends ConsumerWidget { + const _BracketRow({required this.bracket}); + + final Bracket bracket; + + Future _confirmDelete(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete bracket?'), + content: Text( + '"${bracket.name}" and all its match data will be permanently removed.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton.tonal( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true) return; + if (!context.mounted) return; + try { + await ref.read(adminBracketsNotifierProvider.notifier).delete(bracket.id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Deleted "${bracket.name}"')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Delete failed: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final created = DateFormat.yMMMd().format(bracket.createdAt); + final totalMatches = bracket.rounds.fold( + 0, + (sum, r) => sum + r.matches.length, + ); + + return Card( + margin: const EdgeInsets.symmetric(vertical: 6), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(bracket.name, style: theme.textTheme.titleMedium), + const SizedBox(height: 6), + Text( + '${bracket.rounds.length} round${bracket.rounds.length == 1 ? '' : 's'} · $totalMatches match${totalMatches == 1 ? '' : 'es'} · Created $created', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () => _confirmDelete(context, ref), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Delete'), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => + context.go('/admin/brackets/${bracket.id}/edit'), + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Edit'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/admin/presentation/events/admin_event_form_screen.dart b/lib/features/admin/presentation/events/admin_event_form_screen.dart new file mode 100644 index 0000000..bbfc054 --- /dev/null +++ b/lib/features/admin/presentation/events/admin_event_form_screen.dart @@ -0,0 +1,339 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../../events/domain/event.dart'; +import '../../application/admin_events_notifier.dart'; + +class AdminEventFormScreen extends ConsumerStatefulWidget { + const AdminEventFormScreen({super.key, this.eventId}); + + /// Null when creating a new event; otherwise the id of the event being + /// edited. + final String? eventId; + + bool get isEdit => eventId != null; + + @override + ConsumerState createState() => + _AdminEventFormScreenState(); +} + +class _AdminEventFormScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _titleCtrl = TextEditingController(); + final _descCtrl = TextEditingController(); + final _locationCtrl = TextEditingController(); + final _imageUrlCtrl = TextEditingController(); + final _teamsRegisteredCtrl = TextEditingController(text: '0'); + final _maxTeamsCtrl = TextEditingController(text: '8'); + + DateTime _date = DateTime.now().add(const Duration(days: 7)); + DateTime _registrationDeadline = DateTime.now().add(const Duration(days: 6)); + EventCategory _category = EventCategory.pickup; + bool _isCancelled = false; + bool _hydrated = false; + bool _submitting = false; + + @override + void initState() { + super.initState(); + if (!widget.isEdit) _hydrated = true; + } + + @override + void dispose() { + _titleCtrl.dispose(); + _descCtrl.dispose(); + _locationCtrl.dispose(); + _imageUrlCtrl.dispose(); + _teamsRegisteredCtrl.dispose(); + _maxTeamsCtrl.dispose(); + super.dispose(); + } + + void _hydrateFrom(Event event) { + if (_hydrated) return; + _titleCtrl.text = event.title; + _descCtrl.text = event.description; + _locationCtrl.text = event.location; + _imageUrlCtrl.text = event.imageUrl ?? ''; + _teamsRegisteredCtrl.text = event.teamsRegistered.toString(); + _maxTeamsCtrl.text = event.maxTeams.toString(); + _date = event.date; + _registrationDeadline = event.registrationDeadline; + _category = event.category; + _isCancelled = event.isCancelled; + _hydrated = true; + } + + Future _pickDate({required bool registration}) async { + final initial = registration ? _registrationDeadline : _date; + final picked = await showDatePicker( + context: context, + initialDate: initial, + firstDate: DateTime(2020), + lastDate: DateTime(2100), + ); + if (picked == null || !mounted) return; + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime(initial), + ); + if (time == null) return; + final merged = DateTime( + picked.year, + picked.month, + picked.day, + time.hour, + time.minute, + ); + setState(() { + if (registration) { + _registrationDeadline = merged; + } else { + _date = merged; + } + }); + } + + Future _submit() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + final id = widget.eventId ?? ''; + final event = Event( + id: id, + title: _titleCtrl.text.trim(), + description: _descCtrl.text.trim(), + date: _date, + location: _locationCtrl.text.trim(), + registrationDeadline: _registrationDeadline, + teamsRegistered: int.tryParse(_teamsRegisteredCtrl.text.trim()) ?? 0, + maxTeams: int.tryParse(_maxTeamsCtrl.text.trim()) ?? 0, + category: _category, + imageUrl: _imageUrlCtrl.text.trim().isEmpty + ? null + : _imageUrlCtrl.text.trim(), + isCancelled: _isCancelled, + ); + + setState(() => _submitting = true); + try { + if (widget.isEdit) { + await ref.read(adminEventsNotifierProvider.notifier).save(event); + } else { + await ref.read(adminEventsNotifierProvider.notifier).create(event); + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(widget.isEdit ? 'Event updated' : 'Event created'), + ), + ); + context.go('/admin/events'); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Save failed: $e'))); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + if (widget.isEdit && !_hydrated) { + final eventsAsync = ref.watch(adminEventsStreamProvider); + final events = eventsAsync.valueOrNull; + if (events != null) { + final match = events.firstWhere( + (e) => e.id == widget.eventId, + orElse: () => _placeholderEvent(), + ); + if (match.id.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _hydrateFrom(match)); + }); + } + } + } + + final theme = Theme.of(context); + final df = DateFormat.yMMMd().add_jm(); + + return Scaffold( + appBar: AppBar( + title: Text(widget.isEdit ? 'EDIT EVENT' : 'NEW EVENT'), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => context.go('/admin/events'), + ), + ), + body: SafeArea( + child: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + controller: _titleCtrl, + decoration: const InputDecoration(labelText: 'Title'), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: SegmentedButton( + segments: const >[ + ButtonSegment( + value: EventCategory.tournament, + label: Text('TOURNAMENT'), + icon: Icon(Icons.emoji_events_outlined), + ), + ButtonSegment( + value: EventCategory.pickup, + label: Text('PICK-UP'), + icon: Icon(Icons.sports_soccer), + ), + ], + selected: {_category}, + onSelectionChanged: (set) => + setState(() => _category = set.first), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _descCtrl, + decoration: const InputDecoration(labelText: 'Description'), + minLines: 3, + maxLines: 6, + ), + const SizedBox(height: 12), + TextFormField( + controller: _locationCtrl, + decoration: const InputDecoration(labelText: 'Location'), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 12), + _DateField( + label: 'Event date & time', + value: df.format(_date), + onTap: () => _pickDate(registration: false), + ), + const SizedBox(height: 12), + _DateField( + label: 'Registration deadline', + value: df.format(_registrationDeadline), + onTap: () => _pickDate(registration: true), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _teamsRegisteredCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Teams registered', + ), + validator: _validateInt, + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: _maxTeamsCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Max teams'), + validator: _validateInt, + ), + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + controller: _imageUrlCtrl, + decoration: const InputDecoration( + labelText: 'Image URL (optional)', + ), + ), + const SizedBox(height: 12), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + title: Text('Cancelled', style: theme.textTheme.bodyLarge), + subtitle: Text( + 'Mark this event as cancelled', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + value: _isCancelled, + onChanged: (v) => setState(() => _isCancelled = v), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _submitting ? null : _submit, + icon: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(widget.isEdit ? 'SAVE CHANGES' : 'CREATE EVENT'), + ), + ], + ), + ), + ), + ); + } + + static String? _validateInt(String? v) { + if (v == null || v.trim().isEmpty) return 'Required'; + final n = int.tryParse(v.trim()); + if (n == null || n < 0) return 'Enter a non-negative number'; + return null; + } +} + +Event _placeholderEvent() => Event( + id: '', + title: '', + description: '', + date: DateTime.now(), + location: '', + registrationDeadline: DateTime.now(), + teamsRegistered: 0, + maxTeams: 0, +); + +class _DateField extends StatelessWidget { + const _DateField({ + required this.label, + required this.value, + required this.onTap, + }); + + final String label; + final String value; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + suffixIcon: const Icon(Icons.calendar_today, size: 18), + ), + child: Text(value), + ), + ); + } +} diff --git a/lib/features/admin/presentation/events/admin_events_screen.dart b/lib/features/admin/presentation/events/admin_events_screen.dart new file mode 100644 index 0000000..4b9cecf --- /dev/null +++ b/lib/features/admin/presentation/events/admin_events_screen.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../../events/domain/event.dart'; +import '../../application/admin_events_notifier.dart'; + +class AdminEventsScreen extends ConsumerWidget { + const AdminEventsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final eventsAsync = ref.watch(adminEventsStreamProvider); + final theme = Theme.of(context); + + return Scaffold( + body: eventsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => _ErrorState( + message: '$err', + onRetry: () => ref.invalidate(adminEventsStreamProvider), + ), + data: (events) { + if (events.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.event_busy_outlined, + size: 64, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No events yet', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Tap the + button to create your first event.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + itemCount: events.length, + itemBuilder: (context, index) => _EventRow(event: events[index]), + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => context.go('/admin/events/new'), + icon: const Icon(Icons.add), + label: const Text('NEW EVENT'), + ), + ); + } +} + +class _EventRow extends ConsumerWidget { + const _EventRow({required this.event}); + + final Event event; + + Future _confirmDelete(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete event?'), + content: Text( + '"${event.title}" will be permanently removed. This cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton.tonal( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true) return; + if (!context.mounted) return; + try { + await ref.read(adminEventsNotifierProvider.notifier).delete(event.id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Deleted "${event.title}"')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Delete failed: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final dateLabel = DateFormat.yMMMd().add_jm().format(event.date); + + return Card( + margin: const EdgeInsets.symmetric(vertical: 6), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + event.title, + style: theme.textTheme.titleMedium, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (event.isCancelled) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(2), + ), + child: Text( + 'CANCELLED', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onErrorContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.schedule, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + dateLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + Text( + '${event.teamsRegistered}/${event.maxTeams} teams', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.location_on_outlined, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + event.location, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () => _confirmDelete(context, ref), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Delete'), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => + context.go('/admin/events/${event.id}/edit'), + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Edit'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _ErrorState extends StatelessWidget { + const _ErrorState({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: theme.colorScheme.error, + ), + const SizedBox(height: 12), + Text( + 'Could not load events', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + message, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + FilledButton.tonalIcon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Try again'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/admin/presentation/pending/admin_pending_screen.dart b/lib/features/admin/presentation/pending/admin_pending_screen.dart new file mode 100644 index 0000000..abab259 --- /dev/null +++ b/lib/features/admin/presentation/pending/admin_pending_screen.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../teams/domain/team.dart'; +import '../../../teams/infrastructure/teams_repository.dart'; +import '../../application/admin_teams_notifier.dart'; + +/// Admin panel tab listing teams awaiting approval. Approve and reject +/// actions are surfaced as a row of buttons per card. +class AdminPendingScreen extends ConsumerWidget { + const AdminPendingScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final async = ref.watch(adminTeamsStreamProvider); + + return Scaffold( + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Text('Could not load teams: $e'), + ), + ), + data: (teams) { + final pending = teams.where((t) => t.isPending).toList(); + if (pending.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.pending_actions_outlined, + size: 64, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No pending teams', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'New manager-submitted teams will appear here for ' + 'review.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + itemCount: pending.length, + itemBuilder: (context, i) => _PendingTeamCard(team: pending[i]), + ); + }, + ), + ); + } +} + +class _PendingTeamCard extends ConsumerStatefulWidget { + const _PendingTeamCard({required this.team}); + + final Team team; + + @override + ConsumerState<_PendingTeamCard> createState() => _PendingTeamCardState(); +} + +class _PendingTeamCardState extends ConsumerState<_PendingTeamCard> { + bool _busy = false; + + Future _setStatus(String status) async { + setState(() => _busy = true); + try { + await ref + .read(teamsRepositoryProvider) + .updateTeamStatus(widget.team.id, status); + if (!mounted) return; + final label = status == TeamStatus.approved ? 'approved' : 'rejected'; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${widget.team.name} $label')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Action failed: $e')), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final t = widget.team; + + return Card( + margin: const EdgeInsets.symmetric(vertical: 6), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + backgroundColor: scheme.primaryContainer, + child: Text( + t.name.isEmpty ? '?' : t.name.characters.first, + style: TextStyle( + color: scheme.onPrimaryContainer, + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.name.isEmpty ? 'Unnamed team' : t.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 2), + Text( + t.managerEmail.isEmpty + ? 'No manager email' + : t.managerEmail, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'PENDING', + style: theme.textTheme.labelSmall?.copyWith( + color: Colors.amber.shade300, + fontWeight: FontWeight.w800, + letterSpacing: 1.0, + ), + ), + ), + ], + ), + if (t.description != null && t.description!.isNotEmpty) ...[ + const SizedBox(height: 10), + Text( + t.description!, + style: theme.textTheme.bodyMedium, + ), + ], + const SizedBox(height: 10), + Row( + children: [ + Icon( + Icons.group_outlined, + size: 16, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + '${t.players.length} ' + '${t.players.length == 1 ? "player" : "players"}', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _busy + ? null + : () => _setStatus(TeamStatus.rejected), + icon: const Icon(Icons.close, size: 18), + label: const Text('REJECT'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + onPressed: _busy + ? null + : () => _setStatus(TeamStatus.approved), + icon: _busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check, size: 18), + label: const Text('APPROVE'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/admin/presentation/suggestions/admin_suggestions_screen.dart b/lib/features/admin/presentation/suggestions/admin_suggestions_screen.dart new file mode 100644 index 0000000..9661967 --- /dev/null +++ b/lib/features/admin/presentation/suggestions/admin_suggestions_screen.dart @@ -0,0 +1,324 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +import '../../../suggestions/domain/suggestion.dart'; +import '../../application/admin_suggestions_notifier.dart'; + +class AdminSuggestionsScreen extends ConsumerWidget { + const AdminSuggestionsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(adminSuggestionsStreamProvider); + final theme = Theme.of(context); + + return Scaffold( + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: theme.colorScheme.error, + ), + const SizedBox(height: 12), + Text( + 'Could not load suggestions', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 6), + Text( + '$err', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + data: (suggestions) { + if (suggestions.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.lightbulb_outline, + size: 64, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No suggestions yet', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Community ideas will show up here as they come in.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + itemCount: suggestions.length, + itemBuilder: (context, i) => + _AdminSuggestionRow(suggestion: suggestions[i]), + ); + }, + ), + ); + } +} + +class _AdminSuggestionRow extends ConsumerWidget { + const _AdminSuggestionRow({required this.suggestion}); + + final Suggestion suggestion; + + String _author() { + if (suggestion.isAnonymous) return 'Anonymous'; + final name = suggestion.displayName?.trim(); + if (name != null && name.isNotEmpty) return name; + final userId = suggestion.userId?.trim(); + if (userId != null && userId.isNotEmpty) return 'User $userId'; + return 'Unknown'; + } + + Future _changeStatus(BuildContext context, WidgetRef ref) async { + final picked = await showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: const Text('Update status'), + children: SuggestionStatus.values + .map( + (s) => SimpleDialogOption( + onPressed: () => Navigator.of(ctx).pop(s), + child: Row( + children: [ + Icon( + s == suggestion.status + ? Icons.radio_button_checked + : Icons.radio_button_off, + size: 18, + ), + const SizedBox(width: 12), + Text(_statusLabel(s)), + ], + ), + ), + ) + .toList(), + ), + ); + if (picked == null || picked == suggestion.status) return; + if (!context.mounted) return; + try { + await ref + .read(adminSuggestionsNotifierProvider.notifier) + .updateStatus(suggestion.id, picked); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Status set to ${_statusLabel(picked)}')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Update failed: $e')), + ); + } + } + } + + Future _confirmDelete(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete suggestion?'), + content: const Text( + 'This will permanently remove the suggestion. Use for spam or duplicates.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton.tonal( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true) return; + if (!context.mounted) return; + try { + await ref + .read(adminSuggestionsNotifierProvider.notifier) + .delete(suggestion.id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Suggestion deleted')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Delete failed: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final dateLabel = + DateFormat.yMMMd().add_jm().format(suggestion.submittedAt); + + return Card( + margin: const EdgeInsets.symmetric(vertical: 6), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + suggestion.text, + style: theme.textTheme.bodyLarge, + ), + ), + const SizedBox(width: 8), + _StatusChip(status: suggestion.status), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Icon( + suggestion.isAnonymous + ? Icons.visibility_off_outlined + : Icons.person_outline, + size: 14, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + _author(), + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + Icon(Icons.schedule, size: 14, color: colors.onSurfaceVariant), + const SizedBox(width: 4), + Text( + dateLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () => _confirmDelete(context, ref), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Delete'), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => _changeStatus(context, ref), + icon: const Icon(Icons.flag_outlined, size: 18), + label: const Text('Status'), + ), + ], + ), + ], + ), + ), + ); + } +} + +String _statusLabel(SuggestionStatus s) { + switch (s) { + case SuggestionStatus.pending: + return 'Pending'; + case SuggestionStatus.reviewed: + return 'Reviewed'; + case SuggestionStatus.implemented: + return 'Implemented'; + } +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({required this.status}); + + final SuggestionStatus status; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + final (Color background, Color foreground, String label) = switch (status) { + SuggestionStatus.pending => ( + colors.surfaceContainerHighest, + colors.onSurfaceVariant, + 'Pending', + ), + SuggestionStatus.reviewed => ( + Colors.amber.withValues(alpha: 0.18), + Colors.amber.shade300, + 'Reviewed', + ), + SuggestionStatus.implemented => ( + Colors.green.withValues(alpha: 0.18), + Colors.green.shade300, + 'Implemented', + ), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/lib/features/admin/presentation/teams/admin_team_form_screen.dart b/lib/features/admin/presentation/teams/admin_team_form_screen.dart new file mode 100644 index 0000000..44feb1b --- /dev/null +++ b/lib/features/admin/presentation/teams/admin_team_form_screen.dart @@ -0,0 +1,478 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../teams/domain/player.dart'; +import '../../../teams/domain/team.dart'; +import '../../application/admin_teams_notifier.dart'; + +class AdminTeamFormScreen extends ConsumerStatefulWidget { + const AdminTeamFormScreen({super.key, this.teamId}); + + final String? teamId; + + bool get isEdit => teamId != null; + + @override + ConsumerState createState() => + _AdminTeamFormScreenState(); +} + +class _AdminTeamFormScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + final _descCtrl = TextEditingController(); + final _logoUrlCtrl = TextEditingController(); + final _primaryColorCtrl = TextEditingController(); + final _winsCtrl = TextEditingController(text: '0'); + final _lossesCtrl = TextEditingController(text: '0'); + final _drawsCtrl = TextEditingController(text: '0'); + + final List _roster = []; + + /// Preserved across edits so saving doesn't reset a pending team to + /// approved or vice versa. New teams default to [TeamStatus.approved]. + String _status = TeamStatus.approved; + + bool _hydrated = false; + bool _submitting = false; + + @override + void initState() { + super.initState(); + if (!widget.isEdit) _hydrated = true; + } + + @override + void dispose() { + _nameCtrl.dispose(); + _descCtrl.dispose(); + _logoUrlCtrl.dispose(); + _primaryColorCtrl.dispose(); + _winsCtrl.dispose(); + _lossesCtrl.dispose(); + _drawsCtrl.dispose(); + super.dispose(); + } + + void _hydrateFrom(Team team) { + if (_hydrated) return; + _nameCtrl.text = team.name; + _descCtrl.text = team.description ?? ''; + _logoUrlCtrl.text = team.logoUrl ?? ''; + _primaryColorCtrl.text = team.primaryColor ?? ''; + _winsCtrl.text = team.wins.toString(); + _lossesCtrl.text = team.losses.toString(); + _drawsCtrl.text = team.draws.toString(); + _roster + ..clear() + ..addAll(team.players); + _status = team.status; + _hydrated = true; + } + + Future _editPlayer({Player? existing}) async { + final result = await showDialog( + context: context, + builder: (ctx) => _PlayerEditorDialog(player: existing), + ); + if (result == null) return; + setState(() { + if (existing == null) { + _roster.add(result); + } else { + final idx = _roster.indexWhere((p) => p.id == existing.id); + if (idx >= 0) _roster[idx] = result; + } + }); + } + + void _removePlayer(Player p) { + setState(() => _roster.removeWhere((x) => x.id == p.id)); + } + + Future _submit() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + final id = widget.teamId ?? ''; + final team = Team( + id: id, + name: _nameCtrl.text.trim(), + logoUrl: _logoUrlCtrl.text.trim().isEmpty + ? null + : _logoUrlCtrl.text.trim(), + description: + _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(), + wins: int.tryParse(_winsCtrl.text.trim()) ?? 0, + losses: int.tryParse(_lossesCtrl.text.trim()) ?? 0, + draws: int.tryParse(_drawsCtrl.text.trim()) ?? 0, + primaryColor: _primaryColorCtrl.text.trim().isEmpty + ? null + : _primaryColorCtrl.text.trim(), + players: List.unmodifiable(_roster), + status: _status, + ); + + setState(() => _submitting = true); + try { + if (widget.isEdit) { + await ref.read(adminTeamsNotifierProvider.notifier).save(team); + } else { + await ref.read(adminTeamsNotifierProvider.notifier).create(team); + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(widget.isEdit ? 'Team updated' : 'Team created')), + ); + context.go('/admin/teams'); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Save failed: $e')), + ); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + if (widget.isEdit && !_hydrated) { + final teamsAsync = ref.watch(adminTeamsStreamProvider); + final teams = teamsAsync.valueOrNull; + if (teams != null) { + final match = teams.firstWhere( + (t) => t.id == widget.teamId, + orElse: () => const Team(id: '', name: ''), + ); + if (match.id.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _hydrateFrom(match)); + }); + } + } + } + + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: Text(widget.isEdit ? 'EDIT TEAM' : 'NEW TEAM'), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => context.go('/admin/teams'), + ), + ), + body: SafeArea( + child: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration(labelText: 'Team name'), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _descCtrl, + decoration: const InputDecoration(labelText: 'Description'), + minLines: 2, + maxLines: 5, + ), + const SizedBox(height: 12), + TextFormField( + controller: _logoUrlCtrl, + decoration: + const InputDecoration(labelText: 'Logo URL (optional)'), + ), + const SizedBox(height: 12), + TextFormField( + controller: _primaryColorCtrl, + decoration: const InputDecoration( + labelText: 'Primary color hex (e.g. #2E7D32)', + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _winsCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Wins'), + validator: _validateInt, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _lossesCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Losses'), + validator: _validateInt, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _drawsCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Draws'), + validator: _validateInt, + ), + ), + ], + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: Text( + 'ROSTER', + style: theme.textTheme.labelLarge?.copyWith( + letterSpacing: 1.5, + ), + ), + ), + OutlinedButton.icon( + onPressed: () => _editPlayer(), + icon: const Icon(Icons.add, size: 18), + label: const Text('Add player'), + ), + ], + ), + const SizedBox(height: 8), + if (_roster.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + 'No players on the roster yet.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + else + ..._roster.map( + (p) => _PlayerRow( + player: p, + onEdit: () => _editPlayer(existing: p), + onDelete: () => _removePlayer(p), + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _submitting ? null : _submit, + icon: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(widget.isEdit ? 'SAVE CHANGES' : 'CREATE TEAM'), + ), + ], + ), + ), + ), + ); + } + + static String? _validateInt(String? v) { + if (v == null || v.trim().isEmpty) return 'Required'; + final n = int.tryParse(v.trim()); + if (n == null || n < 0) return 'Enter a non-negative number'; + return null; + } +} + +class _PlayerRow extends StatelessWidget { + const _PlayerRow({ + required this.player, + required this.onEdit, + required this.onDelete, + }); + + final Player player; + final VoidCallback onEdit; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + title: Text(player.name), + subtitle: Text( + [ + if (player.position != null && player.position!.isNotEmpty) + player.position!, + 'G ${player.goalsScored}', + 'A ${player.assists}', + ].join(' · '), + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: onEdit, + tooltip: 'Edit player', + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: onDelete, + tooltip: 'Remove player', + ), + ], + ), + ), + ); + } +} + +class _PlayerEditorDialog extends StatefulWidget { + const _PlayerEditorDialog({this.player}); + + final Player? player; + + @override + State<_PlayerEditorDialog> createState() => _PlayerEditorDialogState(); +} + +class _PlayerEditorDialogState extends State<_PlayerEditorDialog> { + final _formKey = GlobalKey(); + late final TextEditingController _nameCtrl; + late final TextEditingController _positionCtrl; + late final TextEditingController _goalsCtrl; + late final TextEditingController _assistsCtrl; + late final TextEditingController _avatarCtrl; + + @override + void initState() { + super.initState(); + final p = widget.player; + _nameCtrl = TextEditingController(text: p?.name ?? ''); + _positionCtrl = TextEditingController(text: p?.position ?? ''); + _goalsCtrl = TextEditingController(text: (p?.goalsScored ?? 0).toString()); + _assistsCtrl = TextEditingController(text: (p?.assists ?? 0).toString()); + _avatarCtrl = TextEditingController(text: p?.avatarUrl ?? ''); + } + + @override + void dispose() { + _nameCtrl.dispose(); + _positionCtrl.dispose(); + _goalsCtrl.dispose(); + _assistsCtrl.dispose(); + _avatarCtrl.dispose(); + super.dispose(); + } + + void _save() { + if (!(_formKey.currentState?.validate() ?? false)) return; + final id = widget.player?.id ?? + 'p_${DateTime.now().microsecondsSinceEpoch.toRadixString(36)}'; + final result = Player( + id: id, + name: _nameCtrl.text.trim(), + position: _positionCtrl.text.trim().isEmpty + ? null + : _positionCtrl.text.trim(), + avatarUrl: _avatarCtrl.text.trim().isEmpty + ? null + : _avatarCtrl.text.trim(), + goalsScored: int.tryParse(_goalsCtrl.text.trim()) ?? 0, + assists: int.tryParse(_assistsCtrl.text.trim()) ?? 0, + ); + Navigator.of(context).pop(result); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.player == null ? 'Add player' : 'Edit player'), + content: SizedBox( + width: 360, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration(labelText: 'Name'), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _positionCtrl, + decoration: + const InputDecoration(labelText: 'Position (optional)'), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _goalsCtrl, + keyboardType: TextInputType.number, + decoration: + const InputDecoration(labelText: 'Goals'), + validator: _validateInt, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _assistsCtrl, + keyboardType: TextInputType.number, + decoration: + const InputDecoration(labelText: 'Assists'), + validator: _validateInt, + ), + ), + ], + ), + const SizedBox(height: 12), + TextFormField( + controller: _avatarCtrl, + decoration: const InputDecoration( + labelText: 'Avatar URL (optional)', + ), + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: _save, + child: const Text('Save'), + ), + ], + ); + } + + static String? _validateInt(String? v) { + if (v == null || v.trim().isEmpty) return 'Required'; + final n = int.tryParse(v.trim()); + if (n == null || n < 0) return 'Enter a non-negative number'; + return null; + } +} diff --git a/lib/features/admin/presentation/teams/admin_teams_screen.dart b/lib/features/admin/presentation/teams/admin_teams_screen.dart new file mode 100644 index 0000000..63faee8 --- /dev/null +++ b/lib/features/admin/presentation/teams/admin_teams_screen.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../teams/domain/team.dart'; +import '../../application/admin_teams_notifier.dart'; + +class AdminTeamsScreen extends ConsumerWidget { + const AdminTeamsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final teamsAsync = ref.watch(adminTeamsStreamProvider); + final theme = Theme.of(context); + + return Scaffold( + body: teamsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 48, + color: theme.colorScheme.error, + ), + const SizedBox(height: 12), + Text('Could not load teams', style: theme.textTheme.titleMedium), + const SizedBox(height: 6), + Text( + '$err', + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + data: (teams) { + if (teams.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.groups_outlined, + size: 64, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No teams yet', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Tap the + button to create your first team.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + itemCount: teams.length, + itemBuilder: (context, index) => _TeamRow(team: teams[index]), + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => context.go('/admin/teams/new'), + icon: const Icon(Icons.add), + label: const Text('NEW TEAM'), + ), + ); + } +} + +class _TeamRow extends ConsumerWidget { + const _TeamRow({required this.team}); + + final Team team; + + Future _confirmDelete(BuildContext context, WidgetRef ref) async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete team?'), + content: Text( + '"${team.name}" and its roster will be permanently removed.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + FilledButton.tonal( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed != true) return; + if (!context.mounted) return; + try { + await ref.read(adminTeamsNotifierProvider.notifier).delete(team.id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Deleted "${team.name}"')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Delete failed: $e')), + ); + } + } + } + + Color? _parseColor(String? hex) { + if (hex == null) return null; + final cleaned = hex.replaceAll('#', '').trim(); + if (cleaned.length != 6) return null; + final value = int.tryParse(cleaned, radix: 16); + if (value == null) return null; + return Color(0xFF000000 | value); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final accent = _parseColor(team.primaryColor) ?? theme.colorScheme.primary; + + return Card( + margin: const EdgeInsets.symmetric(vertical: 6), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 12, + height: 36, + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(team.name, style: theme.textTheme.titleMedium), + Text( + '${team.record} · ${team.players.length} player${team.players.length == 1 ? '' : 's'}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + if (team.description != null && team.description!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + team.description!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton.icon( + onPressed: () => _confirmDelete(context, ref), + icon: const Icon(Icons.delete_outline, size: 18), + label: const Text('Delete'), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => + context.go('/admin/teams/${team.id}/edit'), + icon: const Icon(Icons.edit_outlined, size: 18), + label: const Text('Edit'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/auth/application/auth_notifier.dart b/lib/features/auth/application/auth_notifier.dart new file mode 100644 index 0000000..67c2666 --- /dev/null +++ b/lib/features/auth/application/auth_notifier.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/api/api_client.dart'; +import '../domain/app_user.dart'; +import '../infrastructure/auth_repository.dart'; + +part 'auth_notifier.g.dart'; + +@Riverpod(keepAlive: true) +class AuthNotifier extends _$AuthNotifier { + @override + Future build() async { + final repo = ref.watch(authRepositoryProvider); + + final completer = Completer(); + final sub = repo.authStateChanges().listen( + (user) { + if (!completer.isCompleted) { + completer.complete(user); + } else { + state = AsyncData(user); + } + }, + onError: (Object error, StackTrace stack) { + if (!completer.isCompleted) { + completer.completeError(error, stack); + } else { + state = AsyncError(error, stack); + } + }, + ); + + ref.onDispose(sub.cancel); + return completer.future; + } + + Future signIn({ + required String email, + required String password, + }) async { + final repo = ref.read(authRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => repo.signInWithEmail(email: email, password: password), + ); + } + + Future register({ + required String email, + required String password, + required String displayName, + }) async { + final repo = ref.read(authRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => repo.registerWithEmail( + email: email, + password: password, + displayName: displayName, + ), + ); + } + + Future signOut() async { + final repo = ref.read(authRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + await repo.signOut(); + return null; + }); + } +} + +/// Maps an [ApiException] or generic error to a friendly message. +String authErrorMessage(Object error) { + if (error is ApiException) { + final msg = error.message.toLowerCase(); + if (msg.contains('email already')) return 'An account already exists for that email.'; + if (msg.contains('invalid email')) return 'That email address looks invalid.'; + if (msg.contains('password')) return 'Password must be at least 6 characters.'; + if (msg.contains('invalid email or password')) return 'Incorrect email or password.'; + return error.message; + } + return 'Something went wrong. Please try again.'; +} diff --git a/lib/features/auth/application/auth_notifier.g.dart b/lib/features/auth/application/auth_notifier.g.dart new file mode 100644 index 0000000..e10fe17 --- /dev/null +++ b/lib/features/auth/application/auth_notifier.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package + +part of 'auth_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$authNotifierHash() => r'c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4'; + +/// See also [AuthNotifier]. +@ProviderFor(AuthNotifier) +final authNotifierProvider = + AsyncNotifierProvider.internal( + AuthNotifier.new, + name: r'authNotifierProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$authNotifierHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$AuthNotifier = AsyncNotifier; diff --git a/lib/features/auth/domain/app_user.dart b/lib/features/auth/domain/app_user.dart new file mode 100644 index 0000000..2271432 --- /dev/null +++ b/lib/features/auth/domain/app_user.dart @@ -0,0 +1,48 @@ +/// Immutable domain model representing an authenticated Winded user. +/// +/// This is a pure-Dart model intentionally decoupled from Firebase types so +/// the rest of the app can depend on it without pulling in firebase_auth. +class AppUser { + const AppUser({ + required this.uid, + required this.email, + this.displayName, + this.photoUrl, + }); + + final String uid; + final String email; + final String? displayName; + final String? photoUrl; + + AppUser copyWith({ + String? uid, + String? email, + String? displayName, + String? photoUrl, + }) { + return AppUser( + uid: uid ?? this.uid, + email: email ?? this.email, + displayName: displayName ?? this.displayName, + photoUrl: photoUrl ?? this.photoUrl, + ); + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is AppUser && + other.uid == uid && + other.email == email && + other.displayName == displayName && + other.photoUrl == photoUrl; + } + + @override + int get hashCode => Object.hash(uid, email, displayName, photoUrl); + + @override + String toString() => + 'AppUser(uid: $uid, email: $email, displayName: $displayName)'; +} diff --git a/lib/features/auth/infrastructure/auth_repository.dart b/lib/features/auth/infrastructure/auth_repository.dart new file mode 100644 index 0000000..55151a1 --- /dev/null +++ b/lib/features/auth/infrastructure/auth_repository.dart @@ -0,0 +1,112 @@ +import 'dart:async'; + +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/api/api_client.dart'; +import '../domain/app_user.dart'; + +part 'auth_repository.g.dart'; + +/// Manages auth state backed by the PHP/MySQL API. +/// +/// Because the backend has no push mechanism, auth state is held in memory and +/// exposed via a [StreamController]. Sign-in and registration update the stream +/// immediately; the token is persisted in [FlutterSecureStorage] via [ApiClient]. +class AuthRepository { + AuthRepository(this._api) { + _init(); + } + + final ApiClient _api; + final _controller = StreamController.broadcast(); + + Stream authStateChanges() => _controller.stream; + AppUser? get currentUser => _currentUser; + AppUser? _currentUser; + + Future _init() async { + final token = await _api.token; + if (token == null) { + _emit(null); + return; + } + try { + final data = await _api.get('/auth/me.php'); + final user = _mapUser(data); + _emit(user); + } catch (_) { + await _api.clearToken(); + _emit(null); + } + } + + Future signInWithEmail({ + required String email, + required String password, + }) async { + final data = await _api.post( + '/auth/login.php', + {'email': email.trim(), 'password': password}, + auth: false, + ); + await _api.saveToken(data['token'] as String); + final user = _mapUser(data['user'] as Map); + _emit(user); + return user; + } + + Future registerWithEmail({ + required String email, + required String password, + String? displayName, + }) async { + final data = await _api.post( + '/auth/register.php', + { + 'email': email.trim(), + 'password': password, + 'display_name': displayName?.trim() ?? '', + }, + auth: false, + ); + await _api.saveToken(data['token'] as String); + final user = _mapUser(data['user'] as Map); + _emit(user); + return user; + } + + Future signOut() async { + await _api.clearToken(); + _emit(null); + } + + void _emit(AppUser? user) { + _currentUser = user; + _controller.add(user); + } + + AppUser? _mapUser(Map data) { + final id = data['id'] as String?; + if (id == null || id.isEmpty) return null; + return AppUser( + uid: id, + email: (data['email'] as String?) ?? '', + displayName: data['display_name'] as String?, + photoUrl: data['photo_url'] as String?, + ); + } + + void dispose() => _controller.close(); +} + +@Riverpod(keepAlive: true) +AuthRepository authRepository(AuthRepositoryRef ref) { + final repo = AuthRepository(ref.watch(apiClientProvider)); + ref.onDispose(repo.dispose); + return repo; +} + +@Riverpod(keepAlive: true) +Stream authStateChanges(AuthStateChangesRef ref) { + return ref.watch(authRepositoryProvider).authStateChanges(); +} diff --git a/lib/features/auth/infrastructure/auth_repository.g.dart b/lib/features/auth/infrastructure/auth_repository.g.dart new file mode 100644 index 0000000..64926c6 --- /dev/null +++ b/lib/features/auth/infrastructure/auth_repository.g.dart @@ -0,0 +1,44 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package, deprecated_member_use + +part of 'auth_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$authRepositoryHash() => r'a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2'; + +/// See also [authRepository]. +@ProviderFor(authRepository) +final authRepositoryProvider = Provider.internal( + authRepository, + name: r'authRepositoryProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$authRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef AuthRepositoryRef = ProviderRef; + +String _$authStateChangesHash() => r'b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3'; + +/// See also [authStateChanges]. +@ProviderFor(authStateChanges) +final authStateChangesProvider = StreamProvider.internal( + authStateChanges, + name: r'authStateChangesProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$authStateChangesHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef AuthStateChangesRef = StreamProviderRef; diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart new file mode 100644 index 0000000..6339848 --- /dev/null +++ b/lib/features/auth/presentation/login_screen.dart @@ -0,0 +1,237 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../application/auth_notifier.dart'; +import 'widgets/winded_brand_header.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + static const _purple = Color(0xFF8B30C8); + static const _purpleLight = Color(0xFFBF77F6); + + final _formKey = GlobalKey(); + final _emailCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + bool _obscurePassword = true; + + @override + void dispose() { + _emailCtrl.dispose(); + _passwordCtrl.dispose(); + super.dispose(); + } + + Future _submit() async { + final form = _formKey.currentState; + if (form == null || !form.validate()) return; + FocusScope.of(context).unfocus(); + + await ref.read(authNotifierProvider.notifier).signIn( + email: _emailCtrl.text, + password: _passwordCtrl.text, + ); + + if (!mounted) return; + final state = ref.read(authNotifierProvider); + if (state.hasError) { + final message = authErrorMessage(state.error!); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } + // Successful sign-in triggers router redirect via auth stream — no + // manual navigation needed here. + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final authState = ref.watch(authNotifierProvider); + final isLoading = authState.isLoading; + + return Scaffold( + backgroundColor: colors.surface, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const WindedBrandHeader(), + const SizedBox(height: 8), + Text( + 'SIGN IN TO YOUR PITCH', + textAlign: TextAlign.center, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + letterSpacing: 2.0, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 32), + Container( + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: const Color(0xFF3A3A3A)), + ), + child: Column( + children: [ + Container( + height: 3, + decoration: const BoxDecoration( + color: _purple, + borderRadius: BorderRadius.vertical(top: Radius.circular(4)), + ), + ), + Padding( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _emailCtrl, + enabled: !isLoading, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + textInputAction: TextInputAction.next, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + decoration: const InputDecoration( + labelText: 'EMAIL', + prefixIcon: Icon(Icons.email_outlined), + ), + validator: _validateEmail, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordCtrl, + enabled: !isLoading, + obscureText: _obscurePassword, + autofillHints: const [AutofillHints.password], + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + decoration: InputDecoration( + labelText: 'PASSWORD', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: isLoading + ? null + : () => setState(() { + _obscurePassword = + !_obscurePassword; + }), + ), + ), + validator: (v) { + if (v == null || v.isEmpty) { + return 'Enter your password'; + } + return null; + }, + ), + const SizedBox(height: 24), + FilledButton( + onPressed: isLoading ? null : _submit, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.all(Radius.circular(4)), + ), + ), + child: isLoading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: colors.onPrimary, + ), + ) + : const Text( + 'SIGN IN', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w900, + letterSpacing: 2.0, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'NEW HERE? ', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + letterSpacing: 1.5, + ), + ), + TextButton( + onPressed: isLoading + ? null + : () => context.go('/register'), + style: TextButton.styleFrom( + foregroundColor: _purpleLight, + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + letterSpacing: 1.5, + ), + ), + child: const Text('CREATE AN ACCOUNT'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } + + String? _validateEmail(String? value) { + final trimmed = value?.trim() ?? ''; + if (trimmed.isEmpty) return 'Enter your email'; + final emailRegex = RegExp(r'^[^\s@]+@[^\s@]+\.[^\s@]+$'); + if (!emailRegex.hasMatch(trimmed)) return 'Enter a valid email address'; + return null; + } +} diff --git a/lib/features/auth/presentation/register_screen.dart b/lib/features/auth/presentation/register_screen.dart new file mode 100644 index 0000000..43864e7 --- /dev/null +++ b/lib/features/auth/presentation/register_screen.dart @@ -0,0 +1,392 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../profile/domain/user_profile.dart'; +import '../../profile/infrastructure/profile_repository.dart'; +import '../application/auth_notifier.dart'; +import 'widgets/winded_brand_header.dart'; + +class RegisterScreen extends ConsumerStatefulWidget { + const RegisterScreen({super.key}); + + @override + ConsumerState createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends ConsumerState { + static const _purple = Color(0xFF8B30C8); + static const _purpleLight = Color(0xFFBF77F6); + + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + final _emailCtrl = TextEditingController(); + final _passwordCtrl = TextEditingController(); + final _confirmCtrl = TextEditingController(); + bool _obscurePassword = true; + bool _obscureConfirm = true; + UserRole _selectedRole = UserRole.player; + + @override + void dispose() { + _nameCtrl.dispose(); + _emailCtrl.dispose(); + _passwordCtrl.dispose(); + _confirmCtrl.dispose(); + super.dispose(); + } + + Future _submit() async { + final form = _formKey.currentState; + if (form == null || !form.validate()) return; + FocusScope.of(context).unfocus(); + + final displayName = _nameCtrl.text.trim(); + final email = _emailCtrl.text.trim(); + + await ref.read(authNotifierProvider.notifier).register( + email: email, + password: _passwordCtrl.text, + displayName: displayName, + ); + + if (!mounted) return; + final state = ref.read(authNotifierProvider); + if (state.hasError) { + final message = authErrorMessage(state.error!); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + return; + } + + // Auth succeeded — write the matching Firestore profile so role-based + // gates work immediately. Failures here are surfaced but don't block the + // sign-in flow because the user is already authenticated. + final user = state.valueOrNull; + if (user != null) { + try { + await ref.read(profileRepositoryProvider).createProfile( + UserProfile( + uid: user.uid, + email: user.email, + displayName: displayName.isEmpty + ? (user.displayName ?? '') + : displayName, + role: _selectedRole, + createdAt: DateTime.now(), + ), + ); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text('Could not create profile: $e')), + ); + } + } + } + // On success, Firebase auth stream emits the new user, AuthNotifier + // updates, and the router redirect sends us to /events. + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final authState = ref.watch(authNotifierProvider); + final isLoading = authState.isLoading; + + return Scaffold( + backgroundColor: colors.surface, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const WindedBrandHeader(), + const SizedBox(height: 8), + Text( + 'JOIN THE LEAGUE', + textAlign: TextAlign.center, + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + letterSpacing: 2.0, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 32), + Container( + decoration: BoxDecoration( + color: const Color(0xFF1A1A1A), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: const Color(0xFF3A3A3A)), + ), + child: Column( + children: [ + Container( + height: 3, + decoration: const BoxDecoration( + color: _purple, + borderRadius: BorderRadius.vertical(top: Radius.circular(4)), + ), + ), + Padding( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _nameCtrl, + enabled: !isLoading, + textCapitalization: TextCapitalization.words, + autofillHints: const [AutofillHints.name], + textInputAction: TextInputAction.next, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + decoration: const InputDecoration( + labelText: 'DISPLAY NAME', + prefixIcon: Icon(Icons.person_outline), + ), + validator: (v) { + final trimmed = v?.trim() ?? ''; + if (trimmed.isEmpty) { + return 'Enter your name'; + } + if (trimmed.length < 2) { + return 'Name must be at least 2 characters'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailCtrl, + enabled: !isLoading, + keyboardType: TextInputType.emailAddress, + autofillHints: const [AutofillHints.email], + textInputAction: TextInputAction.next, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + decoration: const InputDecoration( + labelText: 'EMAIL', + prefixIcon: Icon(Icons.email_outlined), + ), + validator: (v) { + final trimmed = v?.trim() ?? ''; + if (trimmed.isEmpty) { + return 'Enter your email'; + } + final emailRegex = RegExp( + r'^[^\s@]+@[^\s@]+\.[^\s@]+$', + ); + if (!emailRegex.hasMatch(trimmed)) { + return 'Enter a valid email address'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordCtrl, + enabled: !isLoading, + obscureText: _obscurePassword, + autofillHints: const [AutofillHints.newPassword], + textInputAction: TextInputAction.next, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + decoration: InputDecoration( + labelText: 'PASSWORD', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _obscurePassword + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: isLoading + ? null + : () => setState(() { + _obscurePassword = + !_obscurePassword; + }), + ), + helperText: 'At least 6 characters', + ), + validator: (v) { + if (v == null || v.isEmpty) { + return 'Enter a password'; + } + if (v.length < 6) { + return 'Password must be at least 6 characters'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _confirmCtrl, + enabled: !isLoading, + obscureText: _obscureConfirm, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w600, + letterSpacing: 0.5, + ), + decoration: InputDecoration( + labelText: 'CONFIRM PASSWORD', + prefixIcon: const Icon(Icons.lock_outline), + suffixIcon: IconButton( + icon: Icon( + _obscureConfirm + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + onPressed: isLoading + ? null + : () => setState(() { + _obscureConfirm = !_obscureConfirm; + }), + ), + ), + validator: (v) { + if (v == null || v.isEmpty) { + return 'Confirm your password'; + } + if (v != _passwordCtrl.text) { + return 'Passwords do not match'; + } + return null; + }, + ), + const SizedBox(height: 20), + Text( + 'I AM A', + style: theme.textTheme.labelSmall?.copyWith( + color: colors.onSurfaceVariant, + letterSpacing: 1.8, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + SegmentedButton( + segments: const >[ + ButtonSegment( + value: UserRole.player, + label: Text('PLAYER'), + icon: Icon(Icons.sports_soccer), + ), + ButtonSegment( + value: UserRole.manager, + label: Text('MANAGER'), + icon: Icon(Icons.shield_outlined), + ), + ], + selected: {_selectedRole}, + onSelectionChanged: isLoading + ? null + : (set) => setState( + () => _selectedRole = set.first, + ), + showSelectedIcon: false, + ), + const SizedBox(height: 8), + Text( + _selectedRole == UserRole.manager + ? 'Managers create and run a team. New teams ' + 'require admin approval.' + : 'Players have a personal profile and can ' + 'request to join a team.', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: isLoading ? null : _submit, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(52), + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.all(Radius.circular(4)), + ), + ), + child: isLoading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: colors.onPrimary, + ), + ) + : const Text( + 'CREATE ACCOUNT', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w900, + letterSpacing: 2.0, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'ALREADY HAVE AN ACCOUNT? ', + style: theme.textTheme.labelMedium?.copyWith( + color: colors.onSurfaceVariant, + letterSpacing: 1.5, + ), + ), + TextButton( + onPressed: isLoading + ? null + : () => context.go('/login'), + style: TextButton.styleFrom( + foregroundColor: _purpleLight, + textStyle: const TextStyle( + fontWeight: FontWeight.w800, + letterSpacing: 1.5, + ), + ), + child: const Text('SIGN IN'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/widgets/winded_brand_header.dart b/lib/features/auth/presentation/widgets/winded_brand_header.dart new file mode 100644 index 0000000..b13d51d --- /dev/null +++ b/lib/features/auth/presentation/widgets/winded_brand_header.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +/// Brand header for the auth screens. Renders the Shadow Oak Pick Up +/// circular badge logo, followed by the league wordmark in heavy +/// uppercase type with wide letter spacing. +class WindedBrandHeader extends StatelessWidget { + const WindedBrandHeader({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + 'assets/images/shadow_oak_logo.jpg', + width: 140, + height: 140, + fit: BoxFit.contain, + ), + const SizedBox(height: 12), + Text( + 'SHADOW OAK', + style: Theme.of(context).textTheme.headlineSmall?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w900, + letterSpacing: 4.0, + ), + ), + Text( + 'PICK UP', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: const Color(0xFFBF77F6), + fontWeight: FontWeight.w700, + letterSpacing: 6.0, + ), + ), + ], + ); + } +} diff --git a/lib/features/brackets/application/brackets_notifier.dart b/lib/features/brackets/application/brackets_notifier.dart new file mode 100644 index 0000000..590832b --- /dev/null +++ b/lib/features/brackets/application/brackets_notifier.dart @@ -0,0 +1,27 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../domain/bracket.dart'; +import '../infrastructure/brackets_repository.dart'; + +part 'brackets_notifier.g.dart'; + +/// Currently-selected bracket id used when navigating to the detail screen. +@riverpod +class SelectedBracketId extends _$SelectedBracketId { + @override + String? build() => null; + + void select(String? id) => state = id; +} + +/// Resolves a single [Bracket] by id out of the brackets stream. Returns null +/// while loading or if no bracket matches. +@riverpod +Bracket? bracketById(BracketByIdRef ref, String id) { + final brackets = ref.watch(bracketsStreamProvider).valueOrNull; + if (brackets == null) return null; + for (final bracket in brackets) { + if (bracket.id == id) return bracket; + } + return null; +} diff --git a/lib/features/brackets/application/brackets_notifier.g.dart b/lib/features/brackets/application/brackets_notifier.g.dart new file mode 100644 index 0000000..1dcf9b6 --- /dev/null +++ b/lib/features/brackets/application/brackets_notifier.g.dart @@ -0,0 +1,183 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'brackets_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$bracketByIdHash() => r'c49c89b5fe87117266a8ca6c2c25009b0b290f60'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// Resolves a single [Bracket] by id out of the brackets stream. Returns null +/// while loading or if no bracket matches. +/// +/// Copied from [bracketById]. +@ProviderFor(bracketById) +const bracketByIdProvider = BracketByIdFamily(); + +/// Resolves a single [Bracket] by id out of the brackets stream. Returns null +/// while loading or if no bracket matches. +/// +/// Copied from [bracketById]. +class BracketByIdFamily extends Family { + /// Resolves a single [Bracket] by id out of the brackets stream. Returns null + /// while loading or if no bracket matches. + /// + /// Copied from [bracketById]. + const BracketByIdFamily(); + + /// Resolves a single [Bracket] by id out of the brackets stream. Returns null + /// while loading or if no bracket matches. + /// + /// Copied from [bracketById]. + BracketByIdProvider call(String id) { + return BracketByIdProvider(id); + } + + @override + BracketByIdProvider getProviderOverride( + covariant BracketByIdProvider provider, + ) { + return call(provider.id); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'bracketByIdProvider'; +} + +/// Resolves a single [Bracket] by id out of the brackets stream. Returns null +/// while loading or if no bracket matches. +/// +/// Copied from [bracketById]. +class BracketByIdProvider extends AutoDisposeProvider { + /// Resolves a single [Bracket] by id out of the brackets stream. Returns null + /// while loading or if no bracket matches. + /// + /// Copied from [bracketById]. + BracketByIdProvider(String id) + : this._internal( + (ref) => bracketById(ref as BracketByIdRef, id), + from: bracketByIdProvider, + name: r'bracketByIdProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bracketByIdHash, + dependencies: BracketByIdFamily._dependencies, + allTransitiveDependencies: BracketByIdFamily._allTransitiveDependencies, + id: id, + ); + + BracketByIdProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.id, + }) : super.internal(); + + final String id; + + @override + Override overrideWith(Bracket? Function(BracketByIdRef provider) create) { + return ProviderOverride( + origin: this, + override: BracketByIdProvider._internal( + (ref) => create(ref as BracketByIdRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + id: id, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _BracketByIdProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is BracketByIdProvider && other.id == id; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, id.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin BracketByIdRef on AutoDisposeProviderRef { + /// The parameter `id` of this provider. + String get id; +} + +class _BracketByIdProviderElement extends AutoDisposeProviderElement + with BracketByIdRef { + _BracketByIdProviderElement(super.provider); + + @override + String get id => (origin as BracketByIdProvider).id; +} + +String _$selectedBracketIdHash() => r'1562a0b74ce4868ad5e49de98e5287551b7a423b'; + +/// Currently-selected bracket id used when navigating to the detail screen. +/// +/// Copied from [SelectedBracketId]. +@ProviderFor(SelectedBracketId) +final selectedBracketIdProvider = + AutoDisposeNotifierProvider.internal( + SelectedBracketId.new, + name: r'selectedBracketIdProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$selectedBracketIdHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$SelectedBracketId = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/brackets/domain/bracket.dart b/lib/features/brackets/domain/bracket.dart new file mode 100644 index 0000000..8b21891 --- /dev/null +++ b/lib/features/brackets/domain/bracket.dart @@ -0,0 +1,322 @@ +/// Lifecycle state of a single bracket match. +enum MatchStatus { scheduled, inProgress, completed } + +/// Lightweight team reference stored inline on a bracket match. The full team +/// record (roster, record, etc.) lives in the teams feature; brackets only +/// need an id, a display name, and an optional logo. +class BracketTeam { + const BracketTeam({ + required this.id, + required this.name, + this.logoUrl, + }); + + final String id; + final String name; + final String? logoUrl; + + BracketTeam copyWith({String? id, String? name, String? logoUrl}) { + return BracketTeam( + id: id ?? this.id, + name: name ?? this.name, + logoUrl: logoUrl ?? this.logoUrl, + ); + } + + factory BracketTeam.fromMap(Map data) { + return BracketTeam( + id: (data['id'] as String?) ?? '', + name: (data['name'] as String?) ?? '', + logoUrl: data['logo_url'] as String?, + ); + } + + Map toMap() { + return { + 'id': id, + 'name': name, + 'logo_url': logoUrl, + }; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is BracketTeam && + other.id == id && + other.name == name && + other.logoUrl == logoUrl; + } + + @override + int get hashCode => Object.hash(id, name, logoUrl); +} + +/// A single match within a bracket. Either team may be null while previous +/// rounds are still being decided (a `null` team renders as "TBD"). +class BracketMatch { + const BracketMatch({ + required this.id, + required this.status, + this.teamA, + this.teamB, + this.scoreA, + this.scoreB, + this.scheduledAt, + this.winnerId, + }); + + final String id; + final BracketTeam? teamA; + final BracketTeam? teamB; + final int? scoreA; + final int? scoreB; + final MatchStatus status; + final DateTime? scheduledAt; + final String? winnerId; + + bool get isTeamAWinner => + winnerId != null && teamA != null && winnerId == teamA!.id; + bool get isTeamBWinner => + winnerId != null && teamB != null && winnerId == teamB!.id; + + BracketMatch copyWith({ + String? id, + BracketTeam? teamA, + BracketTeam? teamB, + int? scoreA, + int? scoreB, + MatchStatus? status, + DateTime? scheduledAt, + String? winnerId, + }) { + return BracketMatch( + id: id ?? this.id, + teamA: teamA ?? this.teamA, + teamB: teamB ?? this.teamB, + scoreA: scoreA ?? this.scoreA, + scoreB: scoreB ?? this.scoreB, + status: status ?? this.status, + scheduledAt: scheduledAt ?? this.scheduledAt, + winnerId: winnerId ?? this.winnerId, + ); + } + + factory BracketMatch.fromMap(Map data) { + return BracketMatch( + id: (data['id'] as String?) ?? '', + teamA: data['team_a'] is Map + ? BracketTeam.fromMap(data['team_a'] as Map) + : null, + teamB: data['team_b'] is Map + ? BracketTeam.fromMap(data['team_b'] as Map) + : null, + scoreA: (data['score_a'] as num?)?.toInt(), + scoreB: (data['score_b'] as num?)?.toInt(), + status: _readStatus(data['status']), + scheduledAt: _readTimestamp(data['scheduled_at']), + winnerId: data['winner_id'] as String?, + ); + } + + Map toMap() { + return { + 'id': id, + 'team_a': teamA?.toMap(), + 'team_b': teamB?.toMap(), + 'score_a': scoreA, + 'score_b': scoreB, + 'status': status.name, + 'scheduled_at': scheduledAt?.toIso8601String(), + 'winner_id': winnerId, + }; + } + + static MatchStatus _readStatus(Object? value) { + if (value is String) { + for (final s in MatchStatus.values) { + if (s.name == value) return s; + } + } + return MatchStatus.scheduled; + } + + static DateTime? _readTimestamp(Object? value) { + if (value is String && value.isNotEmpty) return DateTime.tryParse(value); + if (value is DateTime) return value; + return null; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is BracketMatch && + other.id == id && + other.teamA == teamA && + other.teamB == teamB && + other.scoreA == scoreA && + other.scoreB == scoreB && + other.status == status && + other.scheduledAt == scheduledAt && + other.winnerId == winnerId; + } + + @override + int get hashCode => Object.hash( + id, + teamA, + teamB, + scoreA, + scoreB, + status, + scheduledAt, + winnerId, + ); +} + +/// A round (column) in a bracket — quarterfinals, semifinals, final, etc. +class BracketRound { + const BracketRound({ + required this.roundNumber, + required this.label, + required this.matches, + }); + + final int roundNumber; + final String label; + final List matches; + + BracketRound copyWith({ + int? roundNumber, + String? label, + List? matches, + }) { + return BracketRound( + roundNumber: roundNumber ?? this.roundNumber, + label: label ?? this.label, + matches: matches ?? this.matches, + ); + } + + factory BracketRound.fromMap(Map data) { + final rawMatches = (data['matches'] as List?) ?? const []; + return BracketRound( + roundNumber: (data['round_number'] as num?)?.toInt() ?? 0, + label: (data['label'] as String?) ?? '', + matches: rawMatches + .whereType>() + .map(BracketMatch.fromMap) + .toList(growable: false), + ); + } + + Map toMap() { + return { + 'round_number': roundNumber, + 'label': label, + 'matches': matches.map((m) => m.toMap()).toList(growable: false), + }; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! BracketRound) return false; + if (other.roundNumber != roundNumber) return false; + if (other.label != label) return false; + if (other.matches.length != matches.length) return false; + for (var i = 0; i < matches.length; i++) { + if (other.matches[i] != matches[i]) return false; + } + return true; + } + + @override + int get hashCode => + Object.hash(roundNumber, label, Object.hashAll(matches)); +} + +/// Top-level bracket. A single event may have multiple brackets (e.g. main +/// draw + consolation), so brackets carry an [eventId]. +class Bracket { + const Bracket({ + required this.id, + required this.eventId, + required this.name, + required this.rounds, + required this.createdAt, + }); + + final String id; + final String eventId; + final String name; + final List rounds; + final DateTime createdAt; + + Bracket copyWith({ + String? id, + String? eventId, + String? name, + List? rounds, + DateTime? createdAt, + }) { + return Bracket( + id: id ?? this.id, + eventId: eventId ?? this.eventId, + name: name ?? this.name, + rounds: rounds ?? this.rounds, + createdAt: createdAt ?? this.createdAt, + ); + } + + factory Bracket.fromJson(Map data) { + final rawRounds = (data['rounds'] as List?) ?? const []; + return Bracket( + id: (data['id'] as String?) ?? '', + eventId: (data['event_id'] as String?) ?? '', + name: (data['name'] as String?) ?? '', + rounds: rawRounds + .whereType>() + .map(BracketRound.fromMap) + .toList(growable: false), + createdAt: _readDate(data['created_at']) ?? DateTime.now(), + ); + } + + Map toJson() { + return { + 'event_id': eventId, + 'name': name, + 'rounds': rounds.map((r) => r.toMap()).toList(growable: false), + 'created_at': createdAt.toIso8601String(), + }; + } + + static DateTime? _readDate(Object? value) { + if (value is String && value.isNotEmpty) return DateTime.tryParse(value); + if (value is DateTime) return value; + return null; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! Bracket) return false; + if (other.id != id) return false; + if (other.eventId != eventId) return false; + if (other.name != name) return false; + if (other.createdAt != createdAt) return false; + if (other.rounds.length != rounds.length) return false; + for (var i = 0; i < rounds.length; i++) { + if (other.rounds[i] != rounds[i]) return false; + } + return true; + } + + @override + int get hashCode => + Object.hash(id, eventId, name, createdAt, Object.hashAll(rounds)); + + @override + String toString() => 'Bracket(id: $id, name: $name, rounds: ${rounds.length})'; +} diff --git a/lib/features/brackets/infrastructure/brackets_repository.dart b/lib/features/brackets/infrastructure/brackets_repository.dart new file mode 100644 index 0000000..41a8618 --- /dev/null +++ b/lib/features/brackets/infrastructure/brackets_repository.dart @@ -0,0 +1,80 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/api/api_client.dart'; +import '../domain/bracket.dart'; + +part 'brackets_repository.g.dart'; + +class BracketsRepository { + BracketsRepository(this._api); + + final ApiClient _api; + + Future> fetchBrackets() async { + final data = await _api.get('/brackets/index.php'); + final list = (data['brackets'] as List?) ?? []; + return list.whereType>().map(Bracket.fromJson).toList(); + } + + Future getBracket(String id) async { + try { + final data = await _api.get('/brackets/detail.php', params: {'id': id}); + return Bracket.fromJson(data); + } on ApiException catch (e) { + if (e.statusCode == 404) return null; + rethrow; + } + } + + Future createBracket(Bracket bracket) async { + final data = await _api.post('/brackets/index.php', bracket.toJson()); + return data['id'] as String; + } + + Future updateBracket(Bracket bracket) async { + final data = await _api.put( + '/brackets/detail.php', + bracket.toJson(), + params: {'id': bracket.id}, + ); + return Bracket.fromJson(data); + } + + Future deleteBracket(String id) async { + await _api.delete('/brackets/detail.php', params: {'id': id}); + } + + Future updateMatch( + String bracketId, + String roundLabel, + BracketMatch match, + ) async { + final bracket = await getBracket(bracketId); + if (bracket == null) return; + final rounds = bracket.rounds.map((round) { + if (round.label != roundLabel) return round; + final updatedMatches = round.matches + .map((m) => m.id == match.id ? match : m) + .toList(growable: false); + return round.copyWith(matches: updatedMatches); + }).toList(growable: false); + await updateBracket(bracket.copyWith(rounds: rounds)); + } + + Stream> watchBrackets() async* { + yield await fetchBrackets(); + await for (final _ in Stream.periodic(const Duration(seconds: 30))) { + yield await fetchBrackets(); + } + } +} + +@Riverpod(keepAlive: true) +BracketsRepository bracketsRepository(BracketsRepositoryRef ref) { + return BracketsRepository(ref.watch(apiClientProvider)); +} + +@riverpod +Stream> bracketsStream(BracketsStreamRef ref) { + return ref.watch(bracketsRepositoryProvider).watchBrackets(); +} diff --git a/lib/features/brackets/infrastructure/brackets_repository.g.dart b/lib/features/brackets/infrastructure/brackets_repository.g.dart new file mode 100644 index 0000000..b041177 --- /dev/null +++ b/lib/features/brackets/infrastructure/brackets_repository.g.dart @@ -0,0 +1,50 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'brackets_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$bracketsRepositoryHash() => + r'942ebdb136bee1840c05c7d263e6a4e530cc2d4d'; + +/// See also [bracketsRepository]. +@ProviderFor(bracketsRepository) +final bracketsRepositoryProvider = Provider.internal( + bracketsRepository, + name: r'bracketsRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bracketsRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef BracketsRepositoryRef = ProviderRef; +String _$bracketsStreamHash() => r'72d5d17ad76cbfcf900c81d6bcf44f6678e52dfa'; + +/// Stream of brackets surfaced to the UI. Currently emits the mock list as a +/// single tick — swap to `ref.watch(bracketsRepositoryProvider).watchBrackets()` +/// once Firestore is seeded. +/// +/// Copied from [bracketsStream]. +@ProviderFor(bracketsStream) +final bracketsStreamProvider = + AutoDisposeStreamProvider>.internal( + bracketsStream, + name: r'bracketsStreamProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$bracketsStreamHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef BracketsStreamRef = AutoDisposeStreamProviderRef>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/brackets/presentation/bracket_detail_screen.dart b/lib/features/brackets/presentation/bracket_detail_screen.dart new file mode 100644 index 0000000..ecd2e99 --- /dev/null +++ b/lib/features/brackets/presentation/bracket_detail_screen.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../events/application/events_notifier.dart'; +import '../application/brackets_notifier.dart'; +import 'widgets/bracket_tree_widget.dart'; + +/// Full-screen view of a single bracket. Hosts the [BracketTreeWidget] in the +/// body and shows the parent event's title in the AppBar subtitle when +/// available. +class BracketDetailScreen extends ConsumerWidget { + const BracketDetailScreen({super.key, required this.bracketId}); + + final String bracketId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bracket = ref.watch(bracketByIdProvider(bracketId)); + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + if (bracket == null) { + return Scaffold( + appBar: AppBar( + title: const Text('Bracket'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/brackets'), + ), + ), + body: const Center(child: Text('Bracket not found.')), + ); + } + + final event = ref.watch(eventByIdProvider(bracket.eventId)); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/brackets'), + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + bracket.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (event != null) + Text( + event.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.share_outlined), + tooltip: 'Share bracket', + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Sharing brackets is coming soon.'), + ), + ); + }, + ), + ], + ), + body: Column( + children: [ + Expanded( + child: Center( + child: BracketTreeWidget(bracket: bracket), + ), + ), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + border: Border( + top: BorderSide(color: scheme.outlineVariant), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.swap_horiz, + size: 18, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + 'Scroll horizontally to see all rounds', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/brackets/presentation/brackets_screen.dart b/lib/features/brackets/presentation/brackets_screen.dart new file mode 100644 index 0000000..7b2a180 --- /dev/null +++ b/lib/features/brackets/presentation/brackets_screen.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../domain/bracket.dart'; +import '../infrastructure/brackets_repository.dart'; +import 'widgets/bracket_tree_widget.dart'; + +/// Top-level Brackets tab. +/// +/// Routing behavior: +/// * No brackets → empty state. +/// * Exactly one bracket → render its tree inline (the common case for the +/// MVP, where each event has a single main draw). +/// * Multiple brackets → list view, tap to drill into `/brackets/:id`. +class BracketsScreen extends ConsumerWidget { + const BracketsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final bracketsAsync = ref.watch(bracketsStreamProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Brackets')), + body: bracketsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => _ErrorState( + message: error.toString(), + onRetry: () => ref.invalidate(bracketsStreamProvider), + ), + data: (brackets) { + if (brackets.isEmpty) { + return const _EmptyState(); + } + if (brackets.length == 1) { + return _SingleBracketView(bracket: brackets.first); + } + return _BracketsList(brackets: brackets); + }, + ), + ); + } +} + +class _SingleBracketView extends StatelessWidget { + const _SingleBracketView({required this.bracket}); + + final Bracket bracket; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + children: [ + Icon(Icons.emoji_events, color: scheme.primary), + const SizedBox(width: 8), + Expanded( + child: Text( + bracket.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + Expanded( + child: Center(child: BracketTreeWidget(bracket: bracket)), + ), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + border: Border(top: BorderSide(color: scheme.outlineVariant)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.swap_horiz, + size: 18, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + 'Scroll horizontally to see all rounds', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _BracketsList extends StatelessWidget { + const _BracketsList({required this.brackets}); + + final List brackets; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final dateFormat = DateFormat('MMM d, y'); + + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: brackets.length, + separatorBuilder: (_, _) => const SizedBox(height: 4), + itemBuilder: (context, index) { + final bracket = brackets[index]; + final totalMatches = + bracket.rounds.fold(0, (sum, r) => sum + r.matches.length); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => context.go('/brackets/${bracket.id}'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + CircleAvatar( + backgroundColor: scheme.primaryContainer, + foregroundColor: scheme.onPrimaryContainer, + child: const Icon(Icons.emoji_events), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + bracket.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + '${bracket.rounds.length} rounds · ' + '$totalMatches matches · ' + 'Created ${dateFormat.format(bracket.createdAt)}', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: scheme.onSurfaceVariant), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.emoji_events_outlined, + size: 64, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No brackets yet', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Tournament brackets will appear here once an event reaches ' + 'its draw stage.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +class _ErrorState extends StatelessWidget { + const _ErrorState({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, + size: 64, color: theme.colorScheme.error), + const SizedBox(height: 16), + Text( + 'Could not load brackets', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + FilledButton.tonalIcon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Try again'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/brackets/presentation/widgets/bracket_tree_widget.dart b/lib/features/brackets/presentation/widgets/bracket_tree_widget.dart new file mode 100644 index 0000000..c9d1b79 --- /dev/null +++ b/lib/features/brackets/presentation/widgets/bracket_tree_widget.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; + +import '../../domain/bracket.dart'; +import 'round_column.dart'; + +/// Renders a single-elimination bracket as a horizontal scrolling tree. +/// +/// Geometry rules: +/// * Each round is a [RoundColumn] of width [_columnWidth] (220px card area +/// + 20px right gap = 240px). +/// * Round 1 matches are evenly distributed across the available height. +/// * Each subsequent round's match N is centered between matches 2N and +/// 2N+1 of the previous round. +/// * Connector lines are drawn behind the cards by [_ConnectorsPainter]: +/// a horizontal stub leaves each match's right edge, then a vertical +/// segment joins to the horizontal stub entering the next round's match. +class BracketTreeWidget extends StatelessWidget { + const BracketTreeWidget({super.key, required this.bracket}); + + final Bracket bracket; + + // Layout constants. + static const double _cardWidth = 200; + static const double _cardHeight = 80; + static const double _columnGap = 40; + static const double _columnWidth = _cardWidth + _columnGap; // 240 + static const double _matchSlotHeight = 120; // card + status line + spacing + static const double _verticalPadding = 24; + static const double _labelHeight = RoundColumn.labelHeight; + + @override + Widget build(BuildContext context) { + final rounds = bracket.rounds; + if (rounds.isEmpty) { + return Center( + child: Text( + 'This bracket has no rounds yet.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + final firstRoundMatches = rounds.first.matches.length.clamp(1, 1024); + + // Total drawable height inside the column body (below the round label). + final bodyHeight = _matchSlotHeight * firstRoundMatches; + final columnHeight = bodyHeight + _labelHeight + _verticalPadding * 2; + final totalWidth = rounds.length * _columnWidth; + + // Compute card centers per round, in local column-body coordinates + // (i.e. y measured from the top of the Stack that holds the cards). + final centersByRound = >[]; + for (var r = 0; r < rounds.length; r++) { + final matchCount = rounds[r].matches.length; + if (r == 0) { + // Evenly distribute round 1. + final slot = bodyHeight / matchCount; + centersByRound.add([ + for (var i = 0; i < matchCount; i++) + _verticalPadding + slot * (i + 0.5), + ]); + } else { + // Each match centered between its two feeders from previous round. + final prev = centersByRound[r - 1]; + final centers = []; + for (var i = 0; i < matchCount; i++) { + final a = i * 2; + final b = a + 1; + if (b < prev.length) { + centers.add((prev[a] + prev[b]) / 2); + } else if (a < prev.length) { + centers.add(prev[a]); + } else { + centers.add(_verticalPadding + bodyHeight / 2); + } + } + centersByRound.add(centers); + } + } + + final connectorColor = Theme.of(context).colorScheme.outlineVariant; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + width: totalWidth, + height: columnHeight, + child: Stack( + children: [ + // Connector lines drawn first so they sit behind the cards. + Positioned.fill( + child: IgnorePointer( + child: CustomPaint( + painter: _ConnectorsPainter( + rounds: rounds, + centersByRound: centersByRound, + columnWidth: _columnWidth, + cardWidth: _cardWidth, + labelHeight: _labelHeight, + color: connectorColor, + ), + ), + ), + ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var r = 0; r < rounds.length; r++) + RoundColumn( + round: rounds[r], + cardCenters: centersByRound[r], + columnWidth: _columnWidth, + cardWidth: _cardWidth, + cardHeight: _cardHeight, + columnHeight: columnHeight, + ), + ], + ), + ], + ), + ), + ); + } +} + +class _ConnectorsPainter extends CustomPainter { + _ConnectorsPainter({ + required this.rounds, + required this.centersByRound, + required this.columnWidth, + required this.cardWidth, + required this.labelHeight, + required this.color, + }); + + final List rounds; + final List> centersByRound; + final double columnWidth; + final double cardWidth; + final double labelHeight; + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke; + + // For each pair of adjacent rounds, draw connectors from every match in + // the earlier round into its corresponding match in the later round. + for (var r = 0; r < rounds.length - 1; r++) { + final left = centersByRound[r]; + final right = centersByRound[r + 1]; + + // Card horizontal bounds for this column. + final colLeftX = r * columnWidth; + final cardRightX = colLeftX + (columnWidth + cardWidth) / 2; + + final nextColLeftX = (r + 1) * columnWidth; + final nextCardLeftX = nextColLeftX + (columnWidth - cardWidth) / 2; + + final midX = (cardRightX + nextCardLeftX) / 2; + + for (var i = 0; i < left.length; i++) { + // Pair index in next round. + final next = i ~/ 2; + if (next >= right.length) continue; + + final fromY = left[i] + labelHeight; + final toY = right[next] + labelHeight; + + // Right stub from card. + canvas.drawLine(Offset(cardRightX, fromY), Offset(midX, fromY), paint); + // Vertical segment connecting siblings. + canvas.drawLine(Offset(midX, fromY), Offset(midX, toY), paint); + // Left stub into next round's card. + canvas.drawLine( + Offset(midX, toY), + Offset(nextCardLeftX, toY), + paint, + ); + } + } + } + + @override + bool shouldRepaint(covariant _ConnectorsPainter old) { + return old.rounds != rounds || + old.centersByRound != centersByRound || + old.color != color || + old.columnWidth != columnWidth || + old.cardWidth != cardWidth || + old.labelHeight != labelHeight; + } +} diff --git a/lib/features/brackets/presentation/widgets/match_card.dart b/lib/features/brackets/presentation/widgets/match_card.dart new file mode 100644 index 0000000..dcb7f0d --- /dev/null +++ b/lib/features/brackets/presentation/widgets/match_card.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../domain/bracket.dart'; + +/// Compact card representing a single [BracketMatch] inside the bracket tree. +/// +/// Fixed 200x80 footprint so the bracket tree can lay matches out with +/// predictable geometry. If the match is scheduled, an additional date line +/// is rendered directly beneath the card. +class MatchCard extends StatelessWidget { + const MatchCard({ + super.key, + required this.match, + this.width = 200, + this.height = 80, + }); + + final BracketMatch match; + final double width; + final double height; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: width, + height: height, + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: scheme.outlineVariant), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + Expanded( + child: _TeamRow( + team: match.teamA, + score: match.scoreA, + isWinner: match.isTeamAWinner, + ), + ), + Divider(height: 1, thickness: 1, color: scheme.outlineVariant), + Expanded( + child: _TeamRow( + team: match.teamB, + score: match.scoreB, + isWinner: match.isTeamBWinner, + ), + ), + ], + ), + ), + const SizedBox(height: 4), + _StatusLine(match: match, width: width), + ], + ); + } +} + +class _TeamRow extends StatelessWidget { + const _TeamRow({ + required this.team, + required this.score, + required this.isWinner, + }); + + final BracketTeam? team; + final int? score; + final bool isWinner; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final name = team?.name ?? 'TBD'; + final nameStyle = theme.textTheme.bodyMedium?.copyWith( + fontWeight: isWinner ? FontWeight.w700 : FontWeight.w500, + color: team == null + ? scheme.onSurfaceVariant + : (isWinner ? scheme.onPrimaryContainer : scheme.onSurface), + fontStyle: team == null ? FontStyle.italic : FontStyle.normal, + ); + final scoreStyle = theme.textTheme.titleMedium?.copyWith( + fontWeight: isWinner ? FontWeight.w800 : FontWeight.w600, + color: isWinner ? scheme.onPrimaryContainer : scheme.onSurface, + ); + + return Container( + color: isWinner ? scheme.primaryContainer : null, + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + Expanded( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: nameStyle, + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 24, + child: Text( + score?.toString() ?? '', + textAlign: TextAlign.right, + style: scoreStyle, + ), + ), + ], + ), + ); + } +} + +class _StatusLine extends StatelessWidget { + const _StatusLine({required this.match, required this.width}); + + final BracketMatch match; + final double width; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final (Color dot, String label) = switch (match.status) { + MatchStatus.completed => (Colors.green.shade600, 'Final'), + MatchStatus.inProgress => (Colors.amber.shade700, 'Live'), + MatchStatus.scheduled => (scheme.outline, _scheduledLabel(match)), + }; + + return SizedBox( + width: width, + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration(color: dot, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + static String _scheduledLabel(BracketMatch match) { + final scheduled = match.scheduledAt; + if (scheduled == null) return 'Scheduled'; + return DateFormat('MMM d · h:mm a').format(scheduled); + } +} diff --git a/lib/features/brackets/presentation/widgets/round_column.dart b/lib/features/brackets/presentation/widgets/round_column.dart new file mode 100644 index 0000000..7ee7943 --- /dev/null +++ b/lib/features/brackets/presentation/widgets/round_column.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +import '../../domain/bracket.dart'; +import 'match_card.dart'; + +/// A single column in the bracket tree: a round label at the top, then a +/// vertical stack of [MatchCard]s positioned according to the bracket +/// geometry computed by [BracketTreeWidget]. +/// +/// The widget itself does not compute spacing — its parent supplies a +/// per-card vertical offset so all rounds align even when match counts +/// differ between columns. +class RoundColumn extends StatelessWidget { + const RoundColumn({ + super.key, + required this.round, + required this.cardCenters, + required this.columnWidth, + required this.cardWidth, + required this.cardHeight, + required this.columnHeight, + }); + + final BracketRound round; + + /// Vertical center y-coordinate (in this column's local space) for each + /// match card. Same length as `round.matches`. + final List cardCenters; + + final double columnWidth; + final double cardWidth; + final double cardHeight; + final double columnHeight; + + static const double labelHeight = 32; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SizedBox( + width: columnWidth, + height: columnHeight, + child: Column( + children: [ + SizedBox( + height: labelHeight, + child: Center( + child: Text( + round.label, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.primary, + letterSpacing: 0.5, + ), + ), + ), + ), + Expanded( + child: Stack( + clipBehavior: Clip.none, + children: [ + for (var i = 0; i < round.matches.length; i++) + Positioned( + left: (columnWidth - cardWidth) / 2, + top: cardCenters[i] - cardHeight / 2, + width: cardWidth, + child: MatchCard( + match: round.matches[i], + width: cardWidth, + height: cardHeight, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/events/application/events_notifier.dart b/lib/features/events/application/events_notifier.dart new file mode 100644 index 0000000..d1ba473 --- /dev/null +++ b/lib/features/events/application/events_notifier.dart @@ -0,0 +1,28 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../domain/event.dart'; +import '../infrastructure/events_repository.dart'; + +part 'events_notifier.g.dart'; + +/// Holds the currently-selected event id used when navigating to the detail +/// screen. Null when no event is selected. +@riverpod +class SelectedEventId extends _$SelectedEventId { + @override + String? build() => null; + + void select(String? id) => state = id; +} + +/// Resolves a single [Event] by id out of the events stream. Returns null +/// while loading or if no event matches. +@riverpod +Event? eventById(EventByIdRef ref, String id) { + final events = ref.watch(eventsStreamProvider).valueOrNull; + if (events == null) return null; + for (final event in events) { + if (event.id == id) return event; + } + return null; +} diff --git a/lib/features/events/application/events_notifier.g.dart b/lib/features/events/application/events_notifier.g.dart new file mode 100644 index 0000000..c7fe966 --- /dev/null +++ b/lib/features/events/application/events_notifier.g.dart @@ -0,0 +1,182 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'events_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$eventByIdHash() => r'8717d386b9cf44631b1bc606aedab99c63636b33'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// Resolves a single [Event] by id out of the events stream. Returns null +/// while loading or if no event matches. +/// +/// Copied from [eventById]. +@ProviderFor(eventById) +const eventByIdProvider = EventByIdFamily(); + +/// Resolves a single [Event] by id out of the events stream. Returns null +/// while loading or if no event matches. +/// +/// Copied from [eventById]. +class EventByIdFamily extends Family { + /// Resolves a single [Event] by id out of the events stream. Returns null + /// while loading or if no event matches. + /// + /// Copied from [eventById]. + const EventByIdFamily(); + + /// Resolves a single [Event] by id out of the events stream. Returns null + /// while loading or if no event matches. + /// + /// Copied from [eventById]. + EventByIdProvider call(String id) { + return EventByIdProvider(id); + } + + @override + EventByIdProvider getProviderOverride(covariant EventByIdProvider provider) { + return call(provider.id); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'eventByIdProvider'; +} + +/// Resolves a single [Event] by id out of the events stream. Returns null +/// while loading or if no event matches. +/// +/// Copied from [eventById]. +class EventByIdProvider extends AutoDisposeProvider { + /// Resolves a single [Event] by id out of the events stream. Returns null + /// while loading or if no event matches. + /// + /// Copied from [eventById]. + EventByIdProvider(String id) + : this._internal( + (ref) => eventById(ref as EventByIdRef, id), + from: eventByIdProvider, + name: r'eventByIdProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$eventByIdHash, + dependencies: EventByIdFamily._dependencies, + allTransitiveDependencies: EventByIdFamily._allTransitiveDependencies, + id: id, + ); + + EventByIdProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.id, + }) : super.internal(); + + final String id; + + @override + Override overrideWith(Event? Function(EventByIdRef provider) create) { + return ProviderOverride( + origin: this, + override: EventByIdProvider._internal( + (ref) => create(ref as EventByIdRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + id: id, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _EventByIdProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is EventByIdProvider && other.id == id; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, id.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin EventByIdRef on AutoDisposeProviderRef { + /// The parameter `id` of this provider. + String get id; +} + +class _EventByIdProviderElement extends AutoDisposeProviderElement + with EventByIdRef { + _EventByIdProviderElement(super.provider); + + @override + String get id => (origin as EventByIdProvider).id; +} + +String _$selectedEventIdHash() => r'6d48c24938e4ca7c60317e72cfee3bd87823b2cb'; + +/// Holds the currently-selected event id used when navigating to the detail +/// screen. Null when no event is selected. +/// +/// Copied from [SelectedEventId]. +@ProviderFor(SelectedEventId) +final selectedEventIdProvider = + AutoDisposeNotifierProvider.internal( + SelectedEventId.new, + name: r'selectedEventIdProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$selectedEventIdHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$SelectedEventId = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/events/domain/event.dart b/lib/features/events/domain/event.dart new file mode 100644 index 0000000..d8d5505 --- /dev/null +++ b/lib/features/events/domain/event.dart @@ -0,0 +1,129 @@ +enum EventCategory { tournament, pickup } + +class Event { + const Event({ + required this.id, + required this.title, + required this.description, + required this.date, + required this.location, + required this.registrationDeadline, + required this.teamsRegistered, + required this.maxTeams, + this.category = EventCategory.pickup, + this.imageUrl, + this.isCancelled = false, + }); + + final String id; + final String title; + final String description; + final DateTime date; + final String location; + final DateTime registrationDeadline; + final int teamsRegistered; + final int maxTeams; + final EventCategory category; + final String? imageUrl; + final bool isCancelled; + + Event copyWith({ + String? id, + String? title, + String? description, + DateTime? date, + String? location, + DateTime? registrationDeadline, + int? teamsRegistered, + int? maxTeams, + EventCategory? category, + String? imageUrl, + bool? isCancelled, + }) { + return Event( + id: id ?? this.id, + title: title ?? this.title, + description: description ?? this.description, + date: date ?? this.date, + location: location ?? this.location, + registrationDeadline: registrationDeadline ?? this.registrationDeadline, + teamsRegistered: teamsRegistered ?? this.teamsRegistered, + maxTeams: maxTeams ?? this.maxTeams, + category: category ?? this.category, + imageUrl: imageUrl ?? this.imageUrl, + isCancelled: isCancelled ?? this.isCancelled, + ); + } + + factory Event.fromJson(Map data) { + return Event( + id: (data['id'] as String?) ?? '', + title: (data['title'] as String?) ?? '', + description: (data['description'] as String?) ?? '', + date: _parseDate(data['event_date']) ?? DateTime.now(), + location: (data['location'] as String?) ?? '', + registrationDeadline: + _parseDate(data['registration_deadline']) ?? DateTime.now(), + teamsRegistered: (data['teams_registered'] as num?)?.toInt() ?? 0, + maxTeams: (data['max_teams'] as num?)?.toInt() ?? 0, + category: (data['category'] as String?) == 'tournament' + ? EventCategory.tournament + : EventCategory.pickup, + imageUrl: data['image_url'] as String?, + isCancelled: _parseBool(data['is_cancelled']), + ); + } + + Map toJson() { + return { + 'title': title, + 'description': description, + 'event_date': date.toIso8601String(), + 'location': location, + 'registration_deadline': registrationDeadline.toIso8601String(), + 'max_teams': maxTeams, + 'category': category.name, + 'image_url': imageUrl, + 'is_cancelled': isCancelled ? 1 : 0, + }; + } + + static DateTime? _parseDate(Object? v) { + if (v is String && v.isNotEmpty) return DateTime.tryParse(v); + return null; + } + + static bool _parseBool(Object? v) { + if (v is bool) return v; + if (v is int) return v != 0; + if (v is String) return v == '1' || v.toLowerCase() == 'true'; + return false; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Event && + other.id == id && + other.title == title && + other.description == description && + other.date == date && + other.location == location && + other.registrationDeadline == registrationDeadline && + other.teamsRegistered == teamsRegistered && + other.maxTeams == maxTeams && + other.category == category && + other.imageUrl == imageUrl && + other.isCancelled == isCancelled; + } + + @override + int get hashCode => Object.hash( + id, title, description, date, location, + registrationDeadline, teamsRegistered, maxTeams, + category, imageUrl, isCancelled, + ); + + @override + String toString() => 'Event(id: $id, title: $title, date: $date)'; +} diff --git a/lib/features/events/infrastructure/events_repository.dart b/lib/features/events/infrastructure/events_repository.dart new file mode 100644 index 0000000..caf1ab6 --- /dev/null +++ b/lib/features/events/infrastructure/events_repository.dart @@ -0,0 +1,74 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/api/api_client.dart'; +import '../domain/event.dart'; + +part 'events_repository.g.dart'; + +class EventsRepository { + EventsRepository(this._api); + + final ApiClient _api; + + Future> fetchEvents() async { + final data = await _api.get('/events/index.php'); + final list = (data['events'] as List?) ?? []; + return list.whereType>().map(Event.fromJson).toList(); + } + + Future getEvent(String id) async { + try { + final data = await _api.get('/events/detail.php', params: {'id': id}); + return Event.fromJson(data); + } on ApiException catch (e) { + if (e.statusCode == 404) return null; + rethrow; + } + } + + Future createEvent(Event event) async { + final data = await _api.post('/events/index.php', event.toJson()); + return data['id'] as String; + } + + Future updateEvent(Event event) async { + await _api.put('/events/detail.php', event.toJson(), params: {'id': event.id}); + } + + Future deleteEvent(String id) async { + await _api.delete('/events/detail.php', params: {'id': id}); + } + + Future isRegistered(String eventId) async { + final data = await _api.get( + '/events/register.php', + params: {'event_id': eventId}, + ); + return (data['registered'] as bool?) ?? false; + } + + Future register(String eventId) async { + await _api.post('/events/register.php', {'event_id': eventId}); + } + + Future unregister(String eventId) async { + await _api.delete('/events/register.php', params: {'event_id': eventId}); + } + + Stream> watchEvents() async* { + yield await fetchEvents(); + await for (final _ in Stream.periodic(const Duration(seconds: 30))) { + yield await fetchEvents(); + } + } +} + +@Riverpod(keepAlive: true) +EventsRepository eventsRepository(EventsRepositoryRef ref) { + return EventsRepository(ref.watch(apiClientProvider)); +} + +@riverpod +Stream> eventsStream(EventsStreamRef ref) { + return ref.watch(eventsRepositoryProvider).watchEvents(); +} diff --git a/lib/features/events/infrastructure/events_repository.g.dart b/lib/features/events/infrastructure/events_repository.g.dart new file mode 100644 index 0000000..409dbdb --- /dev/null +++ b/lib/features/events/infrastructure/events_repository.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'events_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$eventsRepositoryHash() => r'753d76dd8556bce50755088a8ea0a611bab61d34'; + +/// See also [eventsRepository]. +@ProviderFor(eventsRepository) +final eventsRepositoryProvider = Provider.internal( + eventsRepository, + name: r'eventsRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$eventsRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef EventsRepositoryRef = ProviderRef; +String _$eventsStreamHash() => r'50b8c367793996c2c0fa894fd2694eefbdf4135b'; + +/// Stream of events surfaced to the UI. +/// +/// Currently emits [EventsRepository.mockEvents] as a single tick so the +/// screens render real-looking content without needing Firestore to be +/// seeded. Swap this to `ref.watch(eventsRepositoryProvider).watchEvents()` +/// once the collection has data. +/// +/// Copied from [eventsStream]. +@ProviderFor(eventsStream) +final eventsStreamProvider = AutoDisposeStreamProvider>.internal( + eventsStream, + name: r'eventsStreamProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$eventsStreamHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef EventsStreamRef = AutoDisposeStreamProviderRef>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/events/presentation/event_detail_screen.dart b/lib/features/events/presentation/event_detail_screen.dart new file mode 100644 index 0000000..5aaeb5b --- /dev/null +++ b/lib/features/events/presentation/event_detail_screen.dart @@ -0,0 +1,351 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../application/events_notifier.dart'; +import '../domain/event.dart'; +import '../infrastructure/events_repository.dart'; +import 'widgets/countdown_timer.dart'; +import 'widgets/registration_button.dart'; + +class EventDetailScreen extends ConsumerWidget { + const EventDetailScreen({super.key, required this.eventId}); + + final String eventId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final eventsAsync = ref.watch(eventsStreamProvider); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + if (context.canPop()) { + context.pop(); + } else { + context.go('/events'); + } + }, + ), + title: const Text('Event details'), + ), + body: eventsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => _NotFound( + message: 'Could not load event: $error', + ), + data: (_) { + final event = ref.watch(eventByIdProvider(eventId)); + if (event == null) { + return const _NotFound(message: 'Event not found.'); + } + return _EventDetailBody(event: event); + }, + ), + ); + } +} + +class _EventDetailBody extends StatelessWidget { + const _EventDetailBody({required this.event}); + + final Event event; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + final dateLabel = + DateFormat('EEEE, MMMM d, y · h:mm a').format(event.date); + final deadlineLabel = + DateFormat('EEE, MMM d · h:mm a').format(event.registrationDeadline); + + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth > 720; + final horizontalPadding = isWide ? 32.0 : 16.0; + + return SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: horizontalPadding, + vertical: 16, + ), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Header( + event: event, + dateLabel: dateLabel, + ), + const SizedBox(height: 20), + Align( + alignment: Alignment.centerLeft, + child: CountdownTimer( + target: event.date, + compact: false, + ), + ), + const SizedBox(height: 24), + Text( + 'About this event', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + event.description, + style: theme.textTheme.bodyLarge?.copyWith( + height: 1.45, + color: scheme.onSurface, + ), + ), + const SizedBox(height: 24), + _RegistrationSection( + event: event, + deadlineLabel: deadlineLabel, + ), + const SizedBox(height: 32), + ], + ), + ), + ), + ); + }, + ); + } +} + +class _Header extends StatelessWidget { + const _Header({required this.event, required this.dateLabel}); + + final Event event; + final String dateLabel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: scheme.primaryContainer, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (event.isCancelled) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: scheme.error, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'Cancelled', + style: theme.textTheme.labelMedium?.copyWith( + color: scheme.onError, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + Text( + event.title, + style: theme.textTheme.headlineSmall?.copyWith( + color: scheme.onPrimaryContainer, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.calendar_today_outlined, + size: 18, + color: scheme.onPrimaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + dateLabel, + style: theme.textTheme.bodyLarge?.copyWith( + color: scheme.onPrimaryContainer, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Icon( + Icons.place_outlined, + size: 18, + color: scheme.onPrimaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + event.location, + style: theme.textTheme.bodyLarge?.copyWith( + color: scheme.onPrimaryContainer, + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _RegistrationSection extends StatelessWidget { + const _RegistrationSection({ + required this.event, + required this.deadlineLabel, + }); + + final Event event; + final String deadlineLabel; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final ratio = event.maxTeams == 0 + ? 0.0 + : (event.teamsRegistered / event.maxTeams).clamp(0.0, 1.0).toDouble(); + final deadlinePassed = + DateTime.now().isAfter(event.registrationDeadline); + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Registration', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Icon(Icons.groups_outlined, color: scheme.primary), + const SizedBox(width: 8), + Text( + '${event.teamsRegistered} / ${event.maxTeams} teams', + style: theme.textTheme.titleLarge?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + value: ratio, + minHeight: 8, + backgroundColor: scheme.surfaceContainer, + valueColor: AlwaysStoppedAnimation(scheme.primary), + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Icon( + Icons.timer_off_outlined, + size: 16, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + deadlinePassed + ? 'Registration closed $deadlineLabel' + : 'Registration closes $deadlineLabel', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + RegistrationButton( + fullWidth: true, + enabled: !deadlinePassed && !event.isCancelled, + ), + if (event.maxTeams > 0 && + event.teamsRegistered >= event.maxTeams) ...[ + const SizedBox(height: 8), + Text( + 'Preferred headcount reached — extras are still welcome to drop in.', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ); + } +} + +class _NotFound extends StatelessWidget { + const _NotFound({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.search_off, + size: 64, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text(message, style: theme.textTheme.titleMedium), + const SizedBox(height: 16), + FilledButton.tonalIcon( + onPressed: () => context.go('/events'), + icon: const Icon(Icons.arrow_back), + label: const Text('Back to events'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/events/presentation/events_screen.dart b/lib/features/events/presentation/events_screen.dart new file mode 100644 index 0000000..ba0356d --- /dev/null +++ b/lib/features/events/presentation/events_screen.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../domain/event.dart'; +import '../infrastructure/events_repository.dart'; +import 'widgets/event_card.dart'; + +class EventsScreen extends ConsumerWidget { + const EventsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final eventsAsync = ref.watch(eventsStreamProvider); + + return DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('Events'), + actions: [ + IconButton( + icon: const Icon(Icons.search), + tooltip: 'Search & filter', + onPressed: () {}, + ), + ], + bottom: const TabBar( + tabs: [ + Tab(text: 'ALL'), + Tab(text: 'TOURNAMENTS'), + Tab(text: 'PICK-UP'), + ], + ), + ), + body: eventsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => _ErrorState( + message: error.toString(), + onRetry: () => ref.invalidate(eventsStreamProvider), + ), + data: (events) { + return TabBarView( + children: [ + _EventsList( + events: events, + onRefresh: () => ref.invalidate(eventsStreamProvider), + ), + _EventsList( + events: events + .where((e) => e.category == EventCategory.tournament) + .toList(growable: false), + onRefresh: () => ref.invalidate(eventsStreamProvider), + ), + _EventsList( + events: events + .where((e) => e.category == EventCategory.pickup) + .toList(growable: false), + onRefresh: () => ref.invalidate(eventsStreamProvider), + ), + ], + ); + }, + ), + ), + ); + } +} + +class _EventsList extends StatelessWidget { + const _EventsList({required this.events, required this.onRefresh}); + + final List events; + final VoidCallback onRefresh; + + @override + Widget build(BuildContext context) { + if (events.isEmpty) return const _EmptyState(); + return RefreshIndicator( + onRefresh: () async => onRefresh(), + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: events.length, + itemBuilder: (context, index) => EventCard(event: events[index]), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.sports_soccer, + size: 64, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text('No events scheduled', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Check back soon — new pick-up games and tournaments are posted regularly.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +class _ErrorState extends StatelessWidget { + const _ErrorState({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 64, color: theme.colorScheme.error), + const SizedBox(height: 16), + Text('Could not load events', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + FilledButton.tonalIcon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Try again'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/events/presentation/widgets/countdown_timer.dart b/lib/features/events/presentation/widgets/countdown_timer.dart new file mode 100644 index 0000000..318e02f --- /dev/null +++ b/lib/features/events/presentation/widgets/countdown_timer.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// Live-updating countdown to a target [DateTime]. +/// +/// Rebuilds once per second and renders one of: +/// * "in 3d 4h" — when more than a day out +/// * "in 4h 12m" — when same-day +/// * "in 12m 30s" — within the hour +/// * "Starting now!" — within the final minute window +/// * "Ended" — once the target has passed by more than the [grace] window +/// +/// Pass [compact] true to render only the duration text (used in cards); +/// false renders a labelled card-friendly block (used on the detail screen). +class CountdownTimer extends StatefulWidget { + const CountdownTimer({ + super.key, + required this.target, + this.compact = true, + this.grace = const Duration(minutes: 60), + }); + + final DateTime target; + final bool compact; + + /// How long after [target] we still show "Starting now!" before flipping + /// to "Ended". Defaults to an hour so an in-progress match stays visible. + final Duration grace; + + @override + State createState() => _CountdownTimerState(); +} + +class _CountdownTimerState extends State { + Timer? _timer; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final now = DateTime.now(); + final diff = widget.target.difference(now); + + final label = _formatLabel(diff); + final isLive = diff.isNegative && diff.abs() < widget.grace; + final isEnded = diff.isNegative && diff.abs() >= widget.grace; + + final Color bg; + final Color fg; + if (isEnded) { + bg = scheme.surfaceContainerHighest; + fg = scheme.onSurfaceVariant; + } else if (isLive) { + bg = scheme.tertiaryContainer; + fg = scheme.onTertiaryContainer; + } else { + bg = scheme.primaryContainer; + fg = scheme.onPrimaryContainer; + } + + if (widget.compact) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: fg, + fontWeight: FontWeight.w600, + ), + ), + ); + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isEnded + ? Icons.event_busy + : isLive + ? Icons.sports_soccer + : Icons.timer_outlined, + color: fg, + ), + const SizedBox(width: 12), + Text( + label, + style: theme.textTheme.titleMedium?.copyWith( + color: fg, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); + } + + String _formatLabel(Duration diff) { + if (diff.isNegative) { + if (diff.abs() < widget.grace) return 'Starting now!'; + return 'Ended'; + } + if (diff.inSeconds <= 60) return 'Starting now!'; + + if (diff.inDays >= 1) { + final days = diff.inDays; + final hours = diff.inHours - days * 24; + if (hours == 0) { + return 'in ${days}d'; + } + return 'in ${days}d ${hours}h'; + } + if (diff.inHours >= 1) { + final hours = diff.inHours; + final minutes = diff.inMinutes - hours * 60; + return 'in ${hours}h ${minutes}m'; + } + final minutes = diff.inMinutes; + final seconds = diff.inSeconds - minutes * 60; + return 'in ${minutes}m ${seconds}s'; + } +} diff --git a/lib/features/events/presentation/widgets/event_card.dart b/lib/features/events/presentation/widgets/event_card.dart new file mode 100644 index 0000000..58af082 --- /dev/null +++ b/lib/features/events/presentation/widgets/event_card.dart @@ -0,0 +1,204 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../domain/event.dart'; +import 'countdown_timer.dart'; + +/// Material 3 card representing a single [Event] in the events list. +/// +/// Tap navigates to `/events/:id`. Visual emphasis is given to the title, +/// the countdown chip, and the registration headcount. +class EventCard extends StatelessWidget { + const EventCard({super.key, required this.event}); + + final Event event; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + final dateLabel = DateFormat('EEE, MMM d · h:mm a').format(event.date); + final isFull = + event.teamsRegistered >= event.maxTeams && event.maxTeams > 0; + + return Card( + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: InkWell( + onTap: () => context.go('/events/${event.id}'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + event.title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 12), + CountdownTimer(target: event.date), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + _CategoryChip(category: event.category), + if (event.isCancelled) ...[ + const SizedBox(width: 8), + _CancelledChip(scheme: scheme), + ], + ], + ), + const SizedBox(height: 12), + _IconRow( + icon: Icons.calendar_today_outlined, + color: scheme.onSurfaceVariant, + child: Text( + dateLabel, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 6), + _IconRow( + icon: Icons.place_outlined, + color: scheme.onSurfaceVariant, + child: Text( + event.location, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Icon(Icons.groups_outlined, size: 18, color: scheme.primary), + const SizedBox(width: 6), + Text( + '${event.teamsRegistered} / ${event.maxTeams} teams', + style: theme.textTheme.titleSmall?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + if (isFull) + Text( + 'Preferred count reached', + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.tertiary, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} + +class _IconRow extends StatelessWidget { + const _IconRow({ + required this.icon, + required this.color, + required this.child, + }); + + final IconData icon; + final Color color; + final Widget child; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 6), + Expanded(child: child), + ], + ); + } +} + +class _CancelledChip extends StatelessWidget { + const _CancelledChip({required this.scheme}); + + final ColorScheme scheme; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: scheme.errorContainer, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + 'Cancelled', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: scheme.onErrorContainer, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _CategoryChip extends StatelessWidget { + const _CategoryChip({required this.category}); + + final EventCategory category; + + static const Color _tournamentColor = Color(0xFF8B30C8); + static const Color _pickupColor = Color(0xFF26A69A); + + @override + Widget build(BuildContext context) { + final isTournament = category == EventCategory.tournament; + final color = isTournament ? _tournamentColor : _pickupColor; + final label = isTournament ? 'TOURNAMENT' : 'PICK-UP'; + final icon = isTournament + ? Icons.emoji_events_outlined + : Icons.sports_soccer; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: color.withValues(alpha: 0.55)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 13, color: color), + const SizedBox(width: 4), + Text( + label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: color, + fontWeight: FontWeight.w800, + letterSpacing: 0.8, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/events/presentation/widgets/registration_button.dart b/lib/features/events/presentation/widgets/registration_button.dart new file mode 100644 index 0000000..b690b4b --- /dev/null +++ b/lib/features/events/presentation/widgets/registration_button.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +/// Toggle button representing the current user's registration state for an +/// event. Local-state only for now — a future revision will wire this to +/// Firestore via the events repository. +class RegistrationButton extends StatefulWidget { + const RegistrationButton({ + super.key, + this.initiallyRegistered = false, + this.enabled = true, + this.fullWidth = false, + this.onChanged, + }); + + final bool initiallyRegistered; + final bool enabled; + final bool fullWidth; + final ValueChanged? onChanged; + + @override + State createState() => _RegistrationButtonState(); +} + +class _RegistrationButtonState extends State { + late bool _registered; + + @override + void initState() { + super.initState(); + _registered = widget.initiallyRegistered; + } + + void _toggle() { + setState(() => _registered = !_registered); + widget.onChanged?.call(_registered); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + final child = _registered + ? OutlinedButton.icon( + onPressed: widget.enabled ? _toggle : null, + icon: Icon(Icons.check_circle, color: scheme.primary), + label: const Text('Registered'), + style: OutlinedButton.styleFrom( + foregroundColor: scheme.primary, + side: BorderSide(color: scheme.primary), + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), + ), + ) + : FilledButton.icon( + onPressed: widget.enabled ? _toggle : null, + icon: const Icon(Icons.how_to_reg), + label: const Text('Register'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 14, + ), + ), + ); + + if (widget.fullWidth) { + return SizedBox(width: double.infinity, child: child); + } + return child; + } +} diff --git a/lib/features/media/domain/highlight.dart b/lib/features/media/domain/highlight.dart new file mode 100644 index 0000000..48a09eb --- /dev/null +++ b/lib/features/media/domain/highlight.dart @@ -0,0 +1,46 @@ +/// Immutable domain model for a highlight video entry on the Media screen. +/// +/// [thumbnailUrl] is nullable so the UI can render a placeholder when no +/// thumbnail is available — common while highlights are still being uploaded. +class Highlight { + const Highlight({ + required this.id, + required this.title, + required this.description, + required this.youtubeUrl, + required this.publishedAt, + this.thumbnailUrl, + }); + + final String id; + final String title; + final String description; + final String youtubeUrl; + final String? thumbnailUrl; + final DateTime publishedAt; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! Highlight) return false; + return other.id == id && + other.title == title && + other.description == description && + other.youtubeUrl == youtubeUrl && + other.thumbnailUrl == thumbnailUrl && + other.publishedAt == publishedAt; + } + + @override + int get hashCode => Object.hash( + id, + title, + description, + youtubeUrl, + thumbnailUrl, + publishedAt, + ); + + @override + String toString() => 'Highlight(id: $id, title: $title)'; +} diff --git a/lib/features/media/domain/media_link.dart b/lib/features/media/domain/media_link.dart new file mode 100644 index 0000000..db7a685 --- /dev/null +++ b/lib/features/media/domain/media_link.dart @@ -0,0 +1,36 @@ +/// Social platforms surfaced on the Media screen. The enum values are stable +/// identifiers used for icon mapping and snackbar copy. +enum SocialPlatform { instagram, youtube, twitter, tiktok } + +/// Immutable domain model for a single social media link card on the Media +/// screen. Pairs a [platform] with the community's [handle], a deep [url], +/// and a friendly [displayName]. +class MediaLink { + const MediaLink({ + required this.platform, + required this.handle, + required this.url, + required this.displayName, + }); + + final SocialPlatform platform; + final String handle; + final String url; + final String displayName; + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! MediaLink) return false; + return other.platform == platform && + other.handle == handle && + other.url == url && + other.displayName == displayName; + } + + @override + int get hashCode => Object.hash(platform, handle, url, displayName); + + @override + String toString() => 'MediaLink($platform, $handle)'; +} diff --git a/lib/features/media/infrastructure/media_repository.dart b/lib/features/media/infrastructure/media_repository.dart new file mode 100644 index 0000000..ac0acc5 --- /dev/null +++ b/lib/features/media/infrastructure/media_repository.dart @@ -0,0 +1,73 @@ +import '../domain/highlight.dart'; +import '../domain/media_link.dart'; + +/// Repository for the Media screen content. +/// +/// All content is static for the MVP — social handles and highlight metadata +/// rarely change and don't justify a Firestore round-trip. Future Phase 2 +/// work can swap these getters for a Firestore-backed source if needed +/// (e.g. an admin-editable `media_links` collection). +class MediaRepository { + const MediaRepository(); + + /// Social media accounts featured at the top of the Media screen. + static const List socialLinks = [ + MediaLink( + platform: SocialPlatform.instagram, + handle: '@windedfc_official', + url: 'https://instagram.com/windedfc_official', + displayName: 'Instagram', + ), + MediaLink( + platform: SocialPlatform.youtube, + handle: 'Winded FC', + url: 'https://youtube.com/@windedfc', + displayName: 'YouTube', + ), + MediaLink( + platform: SocialPlatform.twitter, + handle: '@windedfc', + url: 'https://twitter.com/windedfc', + displayName: 'Twitter / X', + ), + MediaLink( + platform: SocialPlatform.tiktok, + handle: '@windedfc', + url: 'https://tiktok.com/@windedfc', + displayName: 'TikTok', + ), + ]; + + /// Highlight reels surfaced in the Media screen feed. Ordered newest first + /// to match how the UI presents them. + static final List highlights = [ + Highlight( + id: 'highlight_summer_kickoff_final', + title: 'Summer Kickoff 7v7 – Final Highlights', + description: + 'Green Eagles vs. Red Lions went the distance — extra time, a ' + 'goal-line clearance, and a winner from 30 yards. Catch every ' + 'turning point from the championship match.', + youtubeUrl: 'https://youtube.com/watch?v=winded_summer_final', + publishedAt: DateTime(2026, 5, 10), + ), + Highlight( + id: 'highlight_best_goals_may_2026', + title: 'Best Goals of the Month – May 2026', + description: + 'Ten goals, one tape. Volleys, scorpion kicks, and a half-pitch ' + 'lob — our community voted, and these are the May standouts.', + youtubeUrl: 'https://youtube.com/watch?v=winded_may_goals', + publishedAt: DateTime(2026, 5, 6), + ), + Highlight( + id: 'highlight_wednesday_pickup', + title: 'Wednesday Night Pick-Up – Top Moments', + description: + 'No standings, no pressure — just the best plays from this week\'s ' + 'open run at Riverside Park. Bring cleats and friends next time.', + youtubeUrl: 'https://youtube.com/watch?v=winded_wed_pickup', + publishedAt: DateTime(2026, 4, 30), + ), + ]; +} diff --git a/lib/features/media/presentation/media_screen.dart b/lib/features/media/presentation/media_screen.dart new file mode 100644 index 0000000..6b3f33e --- /dev/null +++ b/lib/features/media/presentation/media_screen.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +import '../infrastructure/media_repository.dart'; +import 'widgets/highlight_card.dart'; +import 'widgets/social_link_card.dart'; + +/// Top-level Media screen. Promotes community social presence above the fold +/// and a feed of highlight reels below. Data is read directly from +/// [MediaRepository] static getters — no Riverpod state since the content is +/// hardcoded for the MVP. +class MediaScreen extends StatelessWidget { + const MediaScreen({super.key}); + + static const double _maxContentWidth = 760; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final socialLinks = MediaRepository.socialLinks; + final highlights = MediaRepository.highlights; + + return Scaffold( + appBar: AppBar(title: const Text('Media')), + body: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _maxContentWidth), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _SectionHeader( + title: 'Follow Us', + subtitle: 'Stay connected on your favorite platform', + textTheme: theme.textTheme, + ), + const SizedBox(height: 8), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: socialLinks.length, + separatorBuilder: (context, index) => + const SizedBox(height: 8), + itemBuilder: (context, index) { + return SocialLinkCard(link: socialLinks[index]); + }, + ), + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 16), + _SectionHeader( + title: 'Highlights', + subtitle: 'Recent reels and top moments', + textTheme: theme.textTheme, + ), + const SizedBox(height: 8), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: highlights.length, + separatorBuilder: (context, index) => + const SizedBox(height: 12), + itemBuilder: (context, index) { + return HighlightCard(highlight: highlights[index]); + }, + ), + ], + ), + ), + ), + ), + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({ + required this.title, + required this.subtitle, + required this.textTheme, + }); + + final String title; + final String subtitle; + final TextTheme textTheme; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/media/presentation/widgets/highlight_card.dart b/lib/features/media/presentation/widgets/highlight_card.dart new file mode 100644 index 0000000..505a084 --- /dev/null +++ b/lib/features/media/presentation/widgets/highlight_card.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../domain/highlight.dart'; + +/// Card for one highlight reel. Renders: +/// * a 160px thumbnail area (placeholder until [Highlight.thumbnailUrl] is +/// populated — Phase 2 will swap to `Image.network`) +/// * the title, description (clipped to 2 lines), and published date +/// * a "Watch on YouTube" outlined button that surfaces a snackbar +/// placeholder; real launching ships with the `url_launcher` package. +class HighlightCard extends StatelessWidget { + const HighlightCard({super.key, required this.highlight}); + + final Highlight highlight; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final formattedDate = DateFormat.yMMMMd().format(highlight.publishedAt); + + return Card( + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + _Thumbnail(thumbnailUrl: highlight.thumbnailUrl), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + highlight.title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + highlight.description, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 10), + Row( + children: [ + Icon( + Icons.calendar_today_outlined, + size: 14, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 6), + Text( + formattedDate, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 12), + Align( + alignment: Alignment.centerLeft, + child: OutlinedButton.icon( + onPressed: () => _handleWatch(context), + icon: const Icon(Icons.play_arrow, size: 18), + label: const Text('Watch on YouTube'), + ), + ), + ], + ), + ), + ], + ), + ); + } + + void _handleWatch(BuildContext context) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + const SnackBar( + content: Text('Opening YouTube...'), + duration: Duration(seconds: 2), + ), + ); + } +} + +class _Thumbnail extends StatelessWidget { + const _Thumbnail({required this.thumbnailUrl}); + + final String? thumbnailUrl; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + + if (thumbnailUrl == null) { + return Container( + height: 160, + width: double.infinity, + color: scheme.surfaceContainerHighest, + alignment: Alignment.center, + child: Icon( + Icons.play_circle_outline, + size: 48, + color: scheme.primary, + ), + ); + } + + return SizedBox( + height: 160, + width: double.infinity, + child: Image.network( + thumbnailUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + color: scheme.surfaceContainerHighest, + alignment: Alignment.center, + child: Icon( + Icons.broken_image_outlined, + size: 36, + color: scheme.onSurfaceVariant, + ), + ), + loadingBuilder: (context, child, progress) { + if (progress == null) return child; + return Container( + color: scheme.surfaceContainerHighest, + alignment: Alignment.center, + child: const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ); + }, + ), + ); + } +} diff --git a/lib/features/media/presentation/widgets/social_link_card.dart b/lib/features/media/presentation/widgets/social_link_card.dart new file mode 100644 index 0000000..431247e --- /dev/null +++ b/lib/features/media/presentation/widgets/social_link_card.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; + +import '../../domain/media_link.dart'; + +/// Brand colors for each social platform. Hardcoded on purpose — these are +/// the official platform brand colors, not theme tokens, so they remain +/// recognisable regardless of the app's color scheme. +const Map _platformAccent = { + SocialPlatform.instagram: Color(0xFFE1306C), + SocialPlatform.youtube: Color(0xFFFF0000), + SocialPlatform.twitter: Color(0xFF1DA1F2), + SocialPlatform.tiktok: Color(0xFF69C9D0), +}; + +const Map _platformIcon = { + SocialPlatform.instagram: Icons.photo_camera, + SocialPlatform.youtube: Icons.play_circle_filled, + SocialPlatform.twitter: Icons.tag, + SocialPlatform.tiktok: Icons.music_note, +}; + +/// Wide tappable card for a single social platform. Shows a brand-colored +/// icon leading, the platform name + handle as the title/subtitle, and a +/// trailing chevron. Tapping surfaces an "Opening …" snackbar — actual URL +/// launching will be wired up once `url_launcher` is added to pubspec. +class SocialLinkCard extends StatelessWidget { + const SocialLinkCard({super.key, required this.link}); + + final MediaLink link; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final accent = _platformAccent[link.platform] ?? scheme.primary; + final icon = _platformIcon[link.platform] ?? Icons.public; + + return Card( + clipBehavior: Clip.antiAlias, + child: ListTile( + onTap: () => _handleTap(context), + leading: Container( + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: accent.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon(icon, color: accent, size: 24), + ), + title: Text( + link.displayName, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + subtitle: Text( + link.handle, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + trailing: Icon( + Icons.chevron_right, + color: scheme.onSurfaceVariant, + ), + ), + ); + } + + void _handleTap(BuildContext context) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text('Opening ${link.displayName}...'), + duration: const Duration(seconds: 2), + ), + ); + } +} diff --git a/lib/features/profile/application/profile_notifier.dart b/lib/features/profile/application/profile_notifier.dart new file mode 100644 index 0000000..1f76189 --- /dev/null +++ b/lib/features/profile/application/profile_notifier.dart @@ -0,0 +1,38 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../auth/application/auth_notifier.dart'; +import '../../../core/admin/admin_guard.dart'; +import '../domain/user_profile.dart'; +import '../infrastructure/profile_repository.dart'; + +part 'profile_notifier.g.dart'; + +/// Live profile of the currently signed-in user. Emits null while loading or +/// when no user is signed in. +@riverpod +Stream currentProfile(CurrentProfileRef ref) { + final user = ref.watch(authNotifierProvider).valueOrNull; + if (user == null) return Stream.value(null); + return ref.watch(profileRepositoryProvider).watchProfile(user.uid); +} + +/// Resolves the effective [UserRole] for the current session. Admin status is +/// determined by email allow-list first (so seed-data admins work before +/// they've even loaded a profile doc); otherwise the Firestore-stored role is +/// used, defaulting to [UserRole.viewer] when not logged in. +@riverpod +UserRole currentUserRole(CurrentUserRoleRef ref) { + final user = ref.watch(authNotifierProvider).valueOrNull; + if (user == null) return UserRole.viewer; + if (isAdmin(user)) return UserRole.admin; + final profile = ref.watch(currentProfileProvider).valueOrNull; + if (profile == null) return UserRole.viewer; + return profile.role; +} + +/// One-shot lookup of an arbitrary user profile by uid. Used by the public +/// player profile screen. +@riverpod +Future profileById(ProfileByIdRef ref, String uid) { + return ref.watch(profileRepositoryProvider).getProfile(uid); +} diff --git a/lib/features/profile/application/profile_notifier.g.dart b/lib/features/profile/application/profile_notifier.g.dart new file mode 100644 index 0000000..bfe8e6a --- /dev/null +++ b/lib/features/profile/application/profile_notifier.g.dart @@ -0,0 +1,210 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'profile_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$currentProfileHash() => r'85ba418ee60fcd6612e3fd87974ed10e11a32dae'; + +/// Live profile of the currently signed-in user. Emits null while loading or +/// when no user is signed in. +/// +/// Copied from [currentProfile]. +@ProviderFor(currentProfile) +final currentProfileProvider = AutoDisposeStreamProvider.internal( + currentProfile, + name: r'currentProfileProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$currentProfileHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef CurrentProfileRef = AutoDisposeStreamProviderRef; +String _$currentUserRoleHash() => r'ba507519e5fa744f668b87b9685e5454fcf9ab0a'; + +/// Resolves the effective [UserRole] for the current session. Admin status is +/// determined by email allow-list first (so seed-data admins work before +/// they've even loaded a profile doc); otherwise the Firestore-stored role is +/// used, defaulting to [UserRole.viewer] when not logged in. +/// +/// Copied from [currentUserRole]. +@ProviderFor(currentUserRole) +final currentUserRoleProvider = AutoDisposeProvider.internal( + currentUserRole, + name: r'currentUserRoleProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$currentUserRoleHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef CurrentUserRoleRef = AutoDisposeProviderRef; +String _$profileByIdHash() => r'b485a02150bfb480bc4a9ed04b4a66b8c92e2958'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// One-shot lookup of an arbitrary user profile by uid. Used by the public +/// player profile screen. +/// +/// Copied from [profileById]. +@ProviderFor(profileById) +const profileByIdProvider = ProfileByIdFamily(); + +/// One-shot lookup of an arbitrary user profile by uid. Used by the public +/// player profile screen. +/// +/// Copied from [profileById]. +class ProfileByIdFamily extends Family> { + /// One-shot lookup of an arbitrary user profile by uid. Used by the public + /// player profile screen. + /// + /// Copied from [profileById]. + const ProfileByIdFamily(); + + /// One-shot lookup of an arbitrary user profile by uid. Used by the public + /// player profile screen. + /// + /// Copied from [profileById]. + ProfileByIdProvider call(String uid) { + return ProfileByIdProvider(uid); + } + + @override + ProfileByIdProvider getProviderOverride( + covariant ProfileByIdProvider provider, + ) { + return call(provider.uid); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'profileByIdProvider'; +} + +/// One-shot lookup of an arbitrary user profile by uid. Used by the public +/// player profile screen. +/// +/// Copied from [profileById]. +class ProfileByIdProvider extends AutoDisposeFutureProvider { + /// One-shot lookup of an arbitrary user profile by uid. Used by the public + /// player profile screen. + /// + /// Copied from [profileById]. + ProfileByIdProvider(String uid) + : this._internal( + (ref) => profileById(ref as ProfileByIdRef, uid), + from: profileByIdProvider, + name: r'profileByIdProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$profileByIdHash, + dependencies: ProfileByIdFamily._dependencies, + allTransitiveDependencies: ProfileByIdFamily._allTransitiveDependencies, + uid: uid, + ); + + ProfileByIdProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.uid, + }) : super.internal(); + + final String uid; + + @override + Override overrideWith( + FutureOr Function(ProfileByIdRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: ProfileByIdProvider._internal( + (ref) => create(ref as ProfileByIdRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + uid: uid, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _ProfileByIdProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ProfileByIdProvider && other.uid == uid; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, uid.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ProfileByIdRef on AutoDisposeFutureProviderRef { + /// The parameter `uid` of this provider. + String get uid; +} + +class _ProfileByIdProviderElement + extends AutoDisposeFutureProviderElement + with ProfileByIdRef { + _ProfileByIdProviderElement(super.provider); + + @override + String get uid => (origin as ProfileByIdProvider).uid; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/profile/domain/user_profile.dart b/lib/features/profile/domain/user_profile.dart new file mode 100644 index 0000000..8126107 --- /dev/null +++ b/lib/features/profile/domain/user_profile.dart @@ -0,0 +1,123 @@ +enum UserRole { viewer, player, manager, admin } + +UserRole userRoleFromString(String? raw) { + switch (raw) { + case 'admin': + return UserRole.admin; + case 'manager': + return UserRole.manager; + case 'player': + return UserRole.player; + case 'viewer': + return UserRole.viewer; + default: + return UserRole.player; + } +} + +class UserProfile { + const UserProfile({ + required this.uid, + required this.email, + required this.displayName, + required this.role, + this.bio = '', + this.photoUrl, + this.position, + this.teamId, + required this.createdAt, + }); + + final String uid; + final String email; + final String displayName; + final UserRole role; + final String bio; + final String? photoUrl; + final String? position; + final String? teamId; + final DateTime createdAt; + + bool get hasTeam => teamId != null && teamId!.isNotEmpty; + + UserProfile copyWith({ + String? uid, + String? email, + String? displayName, + UserRole? role, + String? bio, + String? photoUrl, + String? position, + String? teamId, + DateTime? createdAt, + }) { + return UserProfile( + uid: uid ?? this.uid, + email: email ?? this.email, + displayName: displayName ?? this.displayName, + role: role ?? this.role, + bio: bio ?? this.bio, + photoUrl: photoUrl ?? this.photoUrl, + position: position ?? this.position, + teamId: teamId ?? this.teamId, + createdAt: createdAt ?? this.createdAt, + ); + } + + UserProfile clearTeam() => copyWith(teamId: null); + + factory UserProfile.fromJson(Map data) { + return UserProfile( + uid: (data['id'] as String?) ?? '', + email: (data['email'] as String?) ?? '', + displayName: (data['display_name'] as String?) ?? '', + role: userRoleFromString(data['role'] as String?), + bio: (data['bio'] as String?) ?? '', + photoUrl: data['photo_url'] as String?, + position: data['position'] as String?, + teamId: data['team_id'] as String?, + createdAt: _parseDate(data['created_at']) ?? DateTime.now(), + ); + } + + Map toJson() { + return { + 'email': email, + 'display_name': displayName, + 'role': role.name, + 'bio': bio, + 'photo_url': photoUrl, + 'position': position, + 'team_id': teamId, + }; + } + + static DateTime? _parseDate(Object? v) { + if (v is String && v.isNotEmpty) return DateTime.tryParse(v); + return null; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is UserProfile && + other.uid == uid && + other.email == email && + other.displayName == displayName && + other.role == role && + other.bio == bio && + other.photoUrl == photoUrl && + other.position == position && + other.teamId == teamId && + other.createdAt == createdAt; + } + + @override + int get hashCode => Object.hash( + uid, email, displayName, role, bio, photoUrl, position, teamId, createdAt, + ); + + @override + String toString() => + 'UserProfile(uid: $uid, role: ${role.name}, teamId: $teamId)'; +} diff --git a/lib/features/profile/infrastructure/profile_repository.dart b/lib/features/profile/infrastructure/profile_repository.dart new file mode 100644 index 0000000..2f0189e --- /dev/null +++ b/lib/features/profile/infrastructure/profile_repository.dart @@ -0,0 +1,57 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/api/api_client.dart'; +import '../domain/user_profile.dart'; + +part 'profile_repository.g.dart'; + +class ProfileRepository { + ProfileRepository(this._api); + + final ApiClient _api; + + Future getProfile(String uid) async { + try { + final data = await _api.get('/profiles/detail.php', params: {'uid': uid}); + return UserProfile.fromJson(data); + } on ApiException catch (e) { + if (e.statusCode == 404) return null; + rethrow; + } + } + + Future createProfile(UserProfile profile) async { + await _api.put('/profiles/detail.php', profile.toJson(), params: {'uid': profile.uid}); + } + + Future updateProfile(UserProfile profile) async { + await _api.put('/profiles/detail.php', profile.toJson(), params: {'uid': profile.uid}); + } + + Future updateTeamId(String uid, String? teamId) async { + await _api.put('/profiles/detail.php', {'team_id': teamId}, params: {'uid': uid}); + } + + Stream watchProfile(String uid) async* { + yield await getProfile(uid); + await for (final _ in Stream.periodic(const Duration(seconds: 30))) { + yield await getProfile(uid); + } + } + + Future> fetchAllPlayers() async { + // The /auth/me.php endpoint only returns one user. + // For the admin player list, re-use profile fetch per user (admin panel). + // For MVP, return empty — admin panel can be extended later. + return []; + } + + Stream> watchAllPlayers() async* { + yield await fetchAllPlayers(); + } +} + +@Riverpod(keepAlive: true) +ProfileRepository profileRepository(ProfileRepositoryRef ref) { + return ProfileRepository(ref.watch(apiClientProvider)); +} diff --git a/lib/features/profile/infrastructure/profile_repository.g.dart b/lib/features/profile/infrastructure/profile_repository.g.dart new file mode 100644 index 0000000..c7e2303 --- /dev/null +++ b/lib/features/profile/infrastructure/profile_repository.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'profile_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$profileRepositoryHash() => r'c1e1c5e820702a3d191905477db9aba9b798dc36'; + +/// See also [profileRepository]. +@ProviderFor(profileRepository) +final profileRepositoryProvider = Provider.internal( + profileRepository, + name: r'profileRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$profileRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ProfileRepositoryRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/profile/presentation/manager_dashboard_screen.dart b/lib/features/profile/presentation/manager_dashboard_screen.dart new file mode 100644 index 0000000..0a83c3e --- /dev/null +++ b/lib/features/profile/presentation/manager_dashboard_screen.dart @@ -0,0 +1,798 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; + +import '../../auth/application/auth_notifier.dart'; +import '../../teams/application/teams_notifier.dart'; +import '../../teams/domain/join_request.dart'; +import '../../teams/domain/player.dart'; +import '../../teams/domain/team.dart'; +import '../../teams/infrastructure/teams_repository.dart'; +import '../application/profile_notifier.dart'; +import '../infrastructure/profile_repository.dart'; + +/// Dashboard for managers — their team's roster, stats inputs, and pending +/// join requests live here. +/// +/// The route redirect guard in `app_router.dart` ensures only managers reach +/// this screen, so we don't re-check inside. +class ManagerDashboardScreen extends ConsumerWidget { + const ManagerDashboardScreen({super.key}); + + static const double _maxContentWidth = 760; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(authNotifierProvider).valueOrNull; + final profileAsync = ref.watch(currentProfileProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('MANAGER DASHBOARD'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/events'), + ), + ), + body: profileAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Could not load: $e')), + data: (profile) { + if (profile == null || user == null) { + return const Center(child: Text('Not signed in.')); + } + if (!profile.hasTeam) { + return _NoTeamYet(onCreate: () => context.go('/teams/new')); + } + final team = ref.watch(teamByIdProvider(profile.teamId!)); + if (team == null) { + // We have the id but the team stream may be filtered (pending + // teams are excluded from the public feed). Fall back to a + // direct fetch. + return _ManagerForPendingTeam(teamId: profile.teamId!); + } + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _maxContentWidth), + child: _DashboardBody(team: team), + ), + ); + }, + ), + ); + } +} + +class _NoTeamYet extends StatelessWidget { + const _NoTeamYet({required this.onCreate}); + + final VoidCallback onCreate; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.add_business_outlined, + size: 64, + color: theme.colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'No team yet', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + 'Create your team to start managing rosters, stats, and join ' + 'requests. Admins review new teams before they appear publicly.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: onCreate, + icon: const Icon(Icons.add), + label: const Text('CREATE A TEAM'), + ), + ], + ), + ), + ); + } +} + +/// Loads the team document directly when the manager's team is pending and +/// therefore excluded from the public teams stream. +class _ManagerForPendingTeam extends ConsumerStatefulWidget { + const _ManagerForPendingTeam({required this.teamId}); + + final String teamId; + + @override + ConsumerState<_ManagerForPendingTeam> createState() => + _ManagerForPendingTeamState(); +} + +class _ManagerForPendingTeamState + extends ConsumerState<_ManagerForPendingTeam> { + Team? _team; + bool _loading = true; + Object? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + try { + final team = await ref.read(teamsRepositoryProvider).getTeam( + widget.teamId, + ); + if (!mounted) return; + setState(() { + _team = team; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e; + _loading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (_loading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center(child: Text('Could not load team: $_error')); + } + if (_team == null) { + return const Center(child: Text('Team not found.')); + } + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 760), + child: _DashboardBody(team: _team!), + ), + ); + } +} + +class _DashboardBody extends ConsumerWidget { + const _DashboardBody({required this.team}); + + final Team team; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + children: [ + _TeamHeaderCard(team: team), + if (team.isPending) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.amber.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.amber.withValues(alpha: 0.4), + ), + ), + child: Row( + children: [ + Icon(Icons.hourglass_bottom, color: Colors.amber.shade300), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Awaiting admin approval. The team will appear publicly ' + 'once approved.', + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ), + ], + if (team.isRejected) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.error.withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: scheme.error.withValues(alpha: 0.4), + ), + ), + child: Row( + children: [ + Icon(Icons.block, color: scheme.error), + const SizedBox(width: 10), + Expanded( + child: Text( + 'This team was rejected by an admin. Contact the league ' + 'for next steps.', + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ), + ], + if (team.isApproved) ...[ + const SizedBox(height: 24), + _SectionHeader(title: 'ROSTER (${team.players.length})'), + const SizedBox(height: 8), + if (team.players.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + 'No players yet — approved join requests will appear here as ' + 'roster entries.', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ) + else + ...team.players.map((p) => _RosterRow(team: team, player: p)), + const SizedBox(height: 24), + _SectionHeader(title: 'JOIN REQUESTS'), + const SizedBox(height: 8), + _JoinRequestsList(team: team), + ], + ], + ); + } +} + +class _TeamHeaderCard extends StatelessWidget { + const _TeamHeaderCard({required this.team}); + + final Team team; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final initial = team.name.isEmpty ? '?' : team.name.characters.first; + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 56, + height: 56, + alignment: Alignment.center, + decoration: BoxDecoration( + color: scheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Text( + initial.toUpperCase(), + style: TextStyle( + color: scheme.onPrimaryContainer, + fontWeight: FontWeight.w800, + fontSize: 26, + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + team.name, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 4), + Text( + 'Record ${team.record} - ${team.players.length} players', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + _TeamStatusChip(status: team.status), + ], + ), + ), + ); + } +} + +class _TeamStatusChip extends StatelessWidget { + const _TeamStatusChip({required this.status}); + + final String status; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final (Color bg, Color fg, String label) = switch (status) { + TeamStatus.pending => ( + Colors.amber.withValues(alpha: 0.18), + Colors.amber.shade300, + 'PENDING', + ), + TeamStatus.rejected => ( + scheme.error.withValues(alpha: 0.18), + scheme.error, + 'REJECTED', + ), + _ => ( + Colors.green.withValues(alpha: 0.18), + Colors.green.shade300, + 'APPROVED', + ), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: fg, + fontWeight: FontWeight.w800, + letterSpacing: 1.0, + ), + ), + ); + } +} + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + children: [ + Text( + title, + style: theme.textTheme.labelLarge?.copyWith( + letterSpacing: 1.4, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(width: 12), + Expanded(child: Divider(color: theme.colorScheme.outlineVariant)), + ], + ); + } +} + +class _RosterRow extends ConsumerWidget { + const _RosterRow({required this.team, required this.player}); + + final Team team; + final Player player; + + Future _editStats(BuildContext context, WidgetRef ref) async { + final result = await showDialog<_StatEdit>( + context: context, + builder: (_) => _EditStatsDialog(player: player), + ); + if (result == null) return; + if (!context.mounted) return; + + final updatedPlayers = team.players + .map( + (p) => p.id == player.id + ? p.copyWith(goalsScored: result.goals, assists: result.assists) + : p, + ) + .toList(growable: false); + try { + await ref.read(teamsRepositoryProvider).updateTeam( + team.copyWith(players: updatedPlayers), + ); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Updated stats for ${player.name}')), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Update failed: $e')), + ); + } + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + leading: CircleAvatar( + backgroundColor: scheme.primaryContainer, + child: Text( + player.name.isEmpty ? '?' : player.name.characters.first, + style: TextStyle( + color: scheme.onPrimaryContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + title: Text( + player.name, + style: theme.textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + 'Goals ${player.goalsScored} - Assists ${player.assists}' + '${player.position == null ? '' : ' - ${player.position}'}', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + trailing: const Icon(Icons.edit_outlined), + onTap: () => _editStats(context, ref), + ), + ); + } +} + +class _StatEdit { + const _StatEdit({required this.goals, required this.assists}); + final int goals; + final int assists; +} + +class _EditStatsDialog extends StatefulWidget { + const _EditStatsDialog({required this.player}); + + final Player player; + + @override + State<_EditStatsDialog> createState() => _EditStatsDialogState(); +} + +class _EditStatsDialogState extends State<_EditStatsDialog> { + late int _goals = widget.player.goalsScored; + late int _assists = widget.player.assists; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text('Edit ${widget.player.name}'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _StatStepper( + label: 'Goals', + value: _goals, + onChanged: (v) => setState(() => _goals = v), + ), + const SizedBox(height: 12), + _StatStepper( + label: 'Assists', + value: _assists, + onChanged: (v) => setState(() => _assists = v), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('CANCEL'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop( + _StatEdit(goals: _goals, assists: _assists), + ), + child: const Text('SAVE'), + ), + ], + ); + } +} + +class _StatStepper extends StatelessWidget { + const _StatStepper({ + required this.label, + required this.value, + required this.onChanged, + }); + + final String label; + final int value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Row( + children: [ + SizedBox( + width: 80, + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + letterSpacing: 1.0, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + icon: const Icon(Icons.remove_circle_outline), + color: scheme.error, + onPressed: value <= 0 ? null : () => onChanged(value - 1), + ), + Container( + width: 56, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(vertical: 6), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$value', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + IconButton( + icon: const Icon(Icons.add_circle_outline), + color: scheme.primary, + onPressed: () => onChanged(value + 1), + ), + const Spacer(), + SizedBox( + width: 56, + child: TextFormField( + initialValue: '$value', + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + decoration: const InputDecoration(isDense: true), + onChanged: (raw) { + final v = int.tryParse(raw); + if (v != null && v >= 0) onChanged(v); + }, + ), + ), + ], + ); + } +} + +class _JoinRequestsList extends ConsumerWidget { + const _JoinRequestsList({required this.team}); + + final Team team; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final async = ref.watch(joinRequestsForTeamProvider(team.id)); + + return async.when( + loading: () => + const Padding(padding: EdgeInsets.all(16), child: LinearProgressIndicator()), + error: (e, _) => Text('Could not load requests: $e'), + data: (requests) { + final pending = requests + .where((r) => r.status == JoinRequestStatus.pending) + .toList(); + if (pending.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Text( + 'No pending requests.', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ); + } + return Column( + children: pending + .map((r) => _RequestRow(team: team, request: r)) + .toList(), + ); + }, + ); + } +} + +class _RequestRow extends ConsumerStatefulWidget { + const _RequestRow({required this.team, required this.request}); + + final Team team; + final JoinRequest request; + + @override + ConsumerState<_RequestRow> createState() => _RequestRowState(); +} + +class _RequestRowState extends ConsumerState<_RequestRow> { + bool _busy = false; + + Future _act({required bool approve}) async { + setState(() => _busy = true); + final repo = ref.read(teamsRepositoryProvider); + final profileRepo = ref.read(profileRepositoryProvider); + try { + if (approve) { + // Mark the request approved. + await repo.updateJoinRequestStatus( + widget.request.id, + JoinRequestStatus.approved.name, + ); + // Stamp the team on the player's profile. + await profileRepo.updateTeamId( + widget.request.playerId, + widget.team.id, + ); + // Add the player to the team roster (if not already there). + final alreadyOnRoster = widget.team.players.any( + (p) => p.id == widget.request.playerId, + ); + if (!alreadyOnRoster) { + final updated = [ + ...widget.team.players, + Player( + id: widget.request.playerId, + name: widget.request.playerName, + ), + ]; + await repo.updateTeam(widget.team.copyWith(players: updated)); + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${widget.request.playerName} approved')), + ); + } else { + await repo.updateJoinRequestStatus( + widget.request.id, + JoinRequestStatus.rejected.name, + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${widget.request.playerName} rejected')), + ); + } + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Action failed: $e')), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final date = DateFormat.yMMMd().format(widget.request.requestedAt); + + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + backgroundColor: scheme.primaryContainer, + child: Text( + widget.request.playerName.isEmpty + ? '?' + : widget.request.playerName.characters.first, + style: TextStyle( + color: scheme.onPrimaryContainer, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.request.playerName, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + Text( + widget.request.playerEmail, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + Text( + date, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: _busy ? null : () => _act(approve: false), + icon: const Icon(Icons.close, size: 18), + label: const Text('REJECT'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton.icon( + onPressed: _busy ? null : () => _act(approve: true), + icon: _busy + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check, size: 18), + label: const Text('APPROVE'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/profile/presentation/my_profile_screen.dart b/lib/features/profile/presentation/my_profile_screen.dart new file mode 100644 index 0000000..9ef4644 --- /dev/null +++ b/lib/features/profile/presentation/my_profile_screen.dart @@ -0,0 +1,538 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../auth/application/auth_notifier.dart'; +import '../../teams/application/teams_notifier.dart'; +import '../application/profile_notifier.dart'; +import '../domain/user_profile.dart'; +import '../infrastructure/profile_repository.dart'; +import 'widgets/role_chip.dart'; + +/// Editable profile screen for the signed-in user. +/// +/// Reads from [currentProfileProvider] and writes back through +/// [profileRepositoryProvider]. Position is a fixed list of four options +/// plus an "unspecified" sentinel so the UI matches the data model. +class MyProfileScreen extends ConsumerStatefulWidget { + const MyProfileScreen({super.key}); + + static const double _maxContentWidth = 760; + + /// Mirrors the values surfaced in the dropdown — `null` means + /// "no position selected" and round-trips as a null Firestore field. + static const List positions = [ + 'Forward', + 'Midfielder', + 'Defender', + 'Goalkeeper', + ]; + + @override + ConsumerState createState() => _MyProfileScreenState(); +} + +class _MyProfileScreenState extends ConsumerState { + final _bioCtrl = TextEditingController(); + final _photoUrlCtrl = TextEditingController(); + String? _position; + bool _editing = false; + bool _saving = false; + String? _hydratedForUid; + + @override + void dispose() { + _bioCtrl.dispose(); + _photoUrlCtrl.dispose(); + super.dispose(); + } + + void _hydrate(UserProfile profile) { + if (_hydratedForUid == profile.uid) return; + _bioCtrl.text = profile.bio; + _photoUrlCtrl.text = profile.photoUrl ?? ''; + _position = profile.position; + _hydratedForUid = profile.uid; + } + + Future _save(UserProfile current) async { + setState(() => _saving = true); + try { + final updated = current.copyWith( + bio: _bioCtrl.text.trim(), + photoUrl: _photoUrlCtrl.text.trim().isEmpty + ? null + : _photoUrlCtrl.text.trim(), + position: _position, + ); + await ref.read(profileRepositoryProvider).updateProfile(updated); + if (!mounted) return; + setState(() => _editing = false); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Profile saved')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Save failed: $e')), + ); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final user = ref.watch(authNotifierProvider).valueOrNull; + final async = ref.watch(currentProfileProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('MY PROFILE'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/events'), + ), + actions: [ + if (!_editing) + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: 'Edit profile', + onPressed: () => setState(() => _editing = true), + ), + ], + ), + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => _ProfileError(message: e.toString()), + data: (profile) { + if (profile == null) { + return _ProfileMissing(email: user?.email ?? ''); + } + _hydrate(profile); + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: MyProfileScreen._maxContentWidth, + ), + child: _MyProfileBody( + profile: profile, + editing: _editing, + saving: _saving, + bioCtrl: _bioCtrl, + photoUrlCtrl: _photoUrlCtrl, + position: _position, + onPositionChanged: (v) => setState(() => _position = v), + onCancel: () { + setState(() { + _editing = false; + // Re-hydrate so any edited fields are discarded. + _bioCtrl.text = profile.bio; + _photoUrlCtrl.text = profile.photoUrl ?? ''; + _position = profile.position; + }); + }, + onSave: () => _save(profile), + ), + ), + ); + }, + ), + ); + } +} + +class _MyProfileBody extends ConsumerWidget { + const _MyProfileBody({ + required this.profile, + required this.editing, + required this.saving, + required this.bioCtrl, + required this.photoUrlCtrl, + required this.position, + required this.onPositionChanged, + required this.onCancel, + required this.onSave, + }); + + final UserProfile profile; + final bool editing; + final bool saving; + final TextEditingController bioCtrl; + final TextEditingController photoUrlCtrl; + final String? position; + final ValueChanged onPositionChanged; + final VoidCallback onCancel; + final VoidCallback onSave; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + final hasPhoto = profile.photoUrl != null && profile.photoUrl!.isNotEmpty; + final initial = profile.displayName.isEmpty + ? (profile.email.isEmpty ? '?' : profile.email.characters.first) + : profile.displayName.characters.first; + + return ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + children: [ + Center( + child: Container( + width: 112, + height: 112, + alignment: Alignment.center, + decoration: BoxDecoration( + color: scheme.primaryContainer, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: scheme.primary.withValues(alpha: 0.25), + blurRadius: 24, + spreadRadius: 2, + ), + ], + ), + child: hasPhoto + ? CircleAvatar( + radius: 56, + backgroundColor: scheme.primaryContainer, + backgroundImage: NetworkImage(profile.photoUrl!), + ) + : Text( + initial.toUpperCase(), + style: TextStyle( + color: scheme.onPrimaryContainer, + fontWeight: FontWeight.w800, + fontSize: 52, + ), + ), + ), + ), + const SizedBox(height: 16), + Center( + child: Text( + profile.displayName.isEmpty ? 'Unnamed' : profile.displayName, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(height: 4), + Center( + child: Text( + profile.email, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 12), + Center(child: RoleChip(role: profile.role)), + const SizedBox(height: 24), + _TeamMembershipCard(profile: profile), + const SizedBox(height: 24), + Text( + 'POSITION', + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + letterSpacing: 1.2, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + if (editing) + DropdownButtonFormField( + value: position, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.sports_outlined), + ), + items: >[ + const DropdownMenuItem( + value: null, + child: Text('—'), + ), + ...MyProfileScreen.positions.map( + (p) => DropdownMenuItem(value: p, child: Text(p)), + ), + ], + onChanged: saving ? null : onPositionChanged, + ) + else + _ReadOnlyField( + icon: Icons.sports_outlined, + value: position == null || position!.isEmpty ? '—' : position!, + ), + const SizedBox(height: 20), + Text( + 'BIO', + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + letterSpacing: 1.2, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + if (editing) + TextField( + controller: bioCtrl, + enabled: !saving, + minLines: 3, + maxLines: 6, + decoration: const InputDecoration( + hintText: 'A few words about your game...', + ), + ) + else + _ReadOnlyField( + icon: Icons.notes_outlined, + value: profile.bio.isEmpty ? '—' : profile.bio, + multiline: true, + ), + const SizedBox(height: 20), + Text( + 'PHOTO URL', + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + letterSpacing: 1.2, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + if (editing) + TextField( + controller: photoUrlCtrl, + enabled: !saving, + decoration: const InputDecoration( + prefixIcon: Icon(Icons.image_outlined), + hintText: 'https://...', + ), + ) + else + _ReadOnlyField( + icon: Icons.image_outlined, + value: (profile.photoUrl ?? '').isEmpty + ? '—' + : profile.photoUrl!, + ), + const SizedBox(height: 28), + if (editing) + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: saving ? null : onCancel, + child: const Text('CANCEL'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton( + onPressed: saving ? null : onSave, + child: saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('SAVE'), + ), + ), + ], + ), + ], + ); + } +} + +class _TeamMembershipCard extends ConsumerWidget { + const _TeamMembershipCard({required this.profile}); + + final UserProfile profile; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + if (!profile.hasTeam && profile.role != UserRole.manager) { + return const SizedBox.shrink(); + } + + final team = profile.hasTeam + ? ref.watch(teamByIdProvider(profile.teamId!)) + : null; + + final isManager = profile.role == UserRole.manager; + + String label; + if (team != null) { + label = team.name; + } else if (profile.hasTeam) { + label = 'Loading team...'; + } else { + label = 'No team yet'; + } + + return Card( + child: ListTile( + leading: Icon( + isManager ? Icons.shield_outlined : Icons.groups_outlined, + color: scheme.primary, + ), + title: Text( + isManager ? 'YOUR TEAM' : 'TEAM MEMBERSHIP', + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + letterSpacing: 1.2, + fontWeight: FontWeight.w700, + ), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + label, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + trailing: profile.hasTeam + ? const Icon(Icons.chevron_right) + : (isManager + ? TextButton( + onPressed: () => context.go('/teams/new'), + child: const Text('CREATE'), + ) + : null), + onTap: profile.hasTeam + ? () => context.go('/teams/${profile.teamId}') + : null, + ), + ); + } +} + +class _ReadOnlyField extends StatelessWidget { + const _ReadOnlyField({ + required this.icon, + required this.value, + this.multiline = false, + }); + + final IconData icon; + final String value; + final bool multiline; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: scheme.outlineVariant), + ), + child: Row( + crossAxisAlignment: multiline + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: scheme.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + value, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurface, + ), + maxLines: multiline ? null : 1, + overflow: multiline ? null : TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +class _ProfileMissing extends StatelessWidget { + const _ProfileMissing({required this.email}); + + final String email; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.person_off_outlined, + size: 56, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text( + 'No profile yet', + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: 8), + Text( + email.isEmpty + ? 'Sign back in to finish setting up your profile.' + : 'A profile for $email has not been created.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +class _ProfileError extends StatelessWidget { + const _ProfileError({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 56, + color: theme.colorScheme.error, + ), + const SizedBox(height: 16), + Text('Could not load profile', + style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/profile/presentation/player_profile_screen.dart b/lib/features/profile/presentation/player_profile_screen.dart new file mode 100644 index 0000000..eea4aa6 --- /dev/null +++ b/lib/features/profile/presentation/player_profile_screen.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../teams/application/teams_notifier.dart'; +import '../application/profile_notifier.dart'; +import '../domain/user_profile.dart'; +import 'widgets/role_chip.dart'; + +/// Public, read-only profile page for any player or manager. +/// +/// Anyone (including signed-out viewers) can land here from a team roster or +/// shared link. +class PlayerProfileScreen extends ConsumerWidget { + const PlayerProfileScreen({super.key, required this.uid}); + + final String uid; + + static const double _maxContentWidth = 760; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(profileByIdProvider(uid)); + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => + context.canPop() ? context.pop() : context.go('/teams'), + ), + title: const Text('PLAYER'), + ), + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Could not load: $e')), + data: (profile) { + if (profile == null) { + return const Center(child: Text('Player not found.')); + } + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _maxContentWidth), + child: _Body(profile: profile), + ), + ); + }, + ), + ); + } +} + +class _Body extends ConsumerWidget { + const _Body({required this.profile}); + + final UserProfile profile; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + final hasPhoto = profile.photoUrl != null && profile.photoUrl!.isNotEmpty; + final initial = profile.displayName.isEmpty + ? '?' + : profile.displayName.characters.first; + + final team = profile.hasTeam + ? ref.watch(teamByIdProvider(profile.teamId!)) + : null; + + return ListView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 32), + children: [ + Center( + child: Container( + width: 112, + height: 112, + alignment: Alignment.center, + decoration: BoxDecoration( + color: scheme.primaryContainer, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: scheme.primary.withValues(alpha: 0.25), + blurRadius: 24, + spreadRadius: 2, + ), + ], + ), + child: hasPhoto + ? CircleAvatar( + radius: 56, + backgroundColor: scheme.primaryContainer, + backgroundImage: NetworkImage(profile.photoUrl!), + ) + : Text( + initial.toUpperCase(), + style: TextStyle( + color: scheme.onPrimaryContainer, + fontWeight: FontWeight.w800, + fontSize: 52, + ), + ), + ), + ), + const SizedBox(height: 16), + Center( + child: Text( + profile.displayName.isEmpty ? 'Unnamed' : profile.displayName, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + ), + const SizedBox(height: 8), + Center(child: RoleChip(role: profile.role)), + if (profile.position != null && profile.position!.isNotEmpty) ...[ + const SizedBox(height: 12), + Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + profile.position!.toUpperCase(), + style: theme.textTheme.labelMedium?.copyWith( + color: scheme.onSurface, + letterSpacing: 1.2, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + const SizedBox(height: 24), + if (team != null) + Card( + child: ListTile( + leading: Icon(Icons.groups_outlined, color: scheme.primary), + title: Text( + 'TEAM', + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + letterSpacing: 1.2, + fontWeight: FontWeight.w700, + ), + ), + subtitle: Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + team.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => context.go('/teams/${team.id}'), + ), + ), + if (profile.bio.isNotEmpty) ...[ + const SizedBox(height: 16), + Text( + 'BIO', + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + letterSpacing: 1.2, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: scheme.outlineVariant), + ), + child: Text( + profile.bio, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ], + ); + } +} diff --git a/lib/features/profile/presentation/widgets/role_chip.dart b/lib/features/profile/presentation/widgets/role_chip.dart new file mode 100644 index 0000000..70227a1 --- /dev/null +++ b/lib/features/profile/presentation/widgets/role_chip.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; + +import '../../domain/user_profile.dart'; + +/// Small color-coded label that names the user's role. Used in the profile +/// header so the role is glanceable on phone widths. +class RoleChip extends StatelessWidget { + const RoleChip({super.key, required this.role}); + + final UserRole role; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + final (Color background, Color foreground, IconData icon, String label) = + switch (role) { + UserRole.admin => ( + scheme.primary.withValues(alpha: 0.18), + scheme.primary, + Icons.verified_user_outlined, + 'ADMIN', + ), + UserRole.manager => ( + Colors.amber.withValues(alpha: 0.18), + Colors.amber.shade300, + Icons.shield_outlined, + 'MANAGER', + ), + UserRole.player => ( + Colors.green.withValues(alpha: 0.18), + Colors.green.shade300, + Icons.sports_soccer, + 'PLAYER', + ), + UserRole.viewer => ( + scheme.surfaceContainerHighest, + scheme.onSurfaceVariant, + Icons.visibility_outlined, + 'VIEWER', + ), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: foreground), + const SizedBox(width: 6), + Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/stats/application/stats_notifier.dart b/lib/features/stats/application/stats_notifier.dart new file mode 100644 index 0000000..04c10c7 --- /dev/null +++ b/lib/features/stats/application/stats_notifier.dart @@ -0,0 +1,69 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../teams/domain/player.dart'; +import '../../teams/domain/team.dart'; +import '../../teams/infrastructure/teams_repository.dart'; + +part 'stats_notifier.g.dart'; + +/// A player paired with the team they belong to. Records are emitted by the +/// stats providers so leaderboard rows can show "Player — Team" without doing +/// a second lookup. +typedef PlayerWithTeam = ({Player player, Team team}); + +/// Top scorers across every team, sorted by goals scored (descending). Ties +/// are broken by assists, then by player name so the order is deterministic. +@riverpod +Future> topScorers(TopScorersRef ref) async { + final teams = await ref.watch(teamsStreamProvider.future); + final entries = [ + for (final team in teams) + for (final player in team.players) (player: player, team: team), + ]; + entries.sort((a, b) { + final byGoals = b.player.goalsScored.compareTo(a.player.goalsScored); + if (byGoals != 0) return byGoals; + final byAssists = b.player.assists.compareTo(a.player.assists); + if (byAssists != 0) return byAssists; + return a.player.name.compareTo(b.player.name); + }); + return entries; +} + +/// Top assisters across every team, sorted by assists (descending). Ties are +/// broken by goals, then by player name. +@riverpod +Future> topAssisters(TopAssistersRef ref) async { + final teams = await ref.watch(teamsStreamProvider.future); + final entries = [ + for (final team in teams) + for (final player in team.players) (player: player, team: team), + ]; + entries.sort((a, b) { + final byAssists = b.player.assists.compareTo(a.player.assists); + if (byAssists != 0) return byAssists; + final byGoals = b.player.goalsScored.compareTo(a.player.goalsScored); + if (byGoals != 0) return byGoals; + return a.player.name.compareTo(b.player.name); + }); + return entries; +} + +/// League standings: teams sorted by wins (desc), then draws (desc), then by +/// fewer losses, then name. The points column shown in the UI is computed as +/// `wins * 3 + draws`. +@riverpod +Future> teamStandings(TeamStandingsRef ref) async { + final teams = await ref.watch(teamsStreamProvider.future); + final sorted = [...teams]; + sorted.sort((a, b) { + final byWins = b.wins.compareTo(a.wins); + if (byWins != 0) return byWins; + final byDraws = b.draws.compareTo(a.draws); + if (byDraws != 0) return byDraws; + final byLosses = a.losses.compareTo(b.losses); + if (byLosses != 0) return byLosses; + return a.name.compareTo(b.name); + }); + return sorted; +} diff --git a/lib/features/stats/application/stats_notifier.g.dart b/lib/features/stats/application/stats_notifier.g.dart new file mode 100644 index 0000000..f660929 --- /dev/null +++ b/lib/features/stats/application/stats_notifier.g.dart @@ -0,0 +1,73 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stats_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$topScorersHash() => r'217ba2c980b0ac979f18b59f6093ba51ba8ab8d2'; + +/// Top scorers across every team, sorted by goals scored (descending). Ties +/// are broken by assists, then by player name so the order is deterministic. +/// +/// Copied from [topScorers]. +@ProviderFor(topScorers) +final topScorersProvider = + AutoDisposeFutureProvider>.internal( + topScorers, + name: r'topScorersProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$topScorersHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef TopScorersRef = AutoDisposeFutureProviderRef>; +String _$topAssistersHash() => r'2f95133f5b72f4e1ae7001e01e6bd57856d04ad4'; + +/// Top assisters across every team, sorted by assists (descending). Ties are +/// broken by goals, then by player name. +/// +/// Copied from [topAssisters]. +@ProviderFor(topAssisters) +final topAssistersProvider = + AutoDisposeFutureProvider>.internal( + topAssisters, + name: r'topAssistersProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$topAssistersHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef TopAssistersRef = AutoDisposeFutureProviderRef>; +String _$teamStandingsHash() => r'644f974075e26a852b073c7bc155a38bb59045d0'; + +/// League standings: teams sorted by wins (desc), then draws (desc), then by +/// fewer losses, then name. The points column shown in the UI is computed as +/// `wins * 3 + draws`. +/// +/// Copied from [teamStandings]. +@ProviderFor(teamStandings) +final teamStandingsProvider = AutoDisposeFutureProvider>.internal( + teamStandings, + name: r'teamStandingsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$teamStandingsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef TeamStandingsRef = AutoDisposeFutureProviderRef>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/stats/presentation/stats_screen.dart b/lib/features/stats/presentation/stats_screen.dart new file mode 100644 index 0000000..e4906b9 --- /dev/null +++ b/lib/features/stats/presentation/stats_screen.dart @@ -0,0 +1,349 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../application/stats_notifier.dart'; +import 'widgets/leaderboard_tile.dart'; +import 'widgets/stat_bar_chart.dart'; +import 'widgets/stats_filter_bar.dart'; + +/// Stats hub: standings + player leaderboards. Driven by [DefaultTabController] +/// so the [StatsFilterBar] in the AppBar bottom slot stays in sync with the +/// [TabBarView] below. +class StatsScreen extends ConsumerWidget { + const StatsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DefaultTabController( + length: 3, + child: Scaffold( + appBar: AppBar( + title: const Text('Stats'), + bottom: const StatsFilterBar(), + ), + body: const TabBarView( + children: [ + _StandingsTab(), + _ScorersTab(), + _AssistsTab(), + ], + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Standings +// --------------------------------------------------------------------------- + +class _StandingsTab extends ConsumerWidget { + const _StandingsTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final standingsAsync = ref.watch(teamStandingsProvider); + return standingsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => _ErrorState( + message: err.toString(), + onRetry: () => ref.invalidate(teamStandingsProvider), + ), + data: (teams) { + if (teams.isEmpty) { + return const _EmptyState( + icon: Icons.emoji_events_outlined, + title: 'No standings yet', + body: 'League standings will appear once teams have played games.', + ); + } + return ListView( + padding: const EdgeInsets.symmetric(vertical: 12), + children: [ + const _SectionHeader(label: 'League standings'), + const _StandingsHeaderRow(), + for (var i = 0; i < teams.length; i++) + LeaderboardTile.team( + rank: i + 1, + team: teams[i], + navContext: context, + ), + const SizedBox(height: 24), + const _SectionHeader(label: 'Wins by team'), + StatBarChart( + valueLabel: 'Wins', + data: [ + for (final t in teams) + StatBarDatum(label: t.name, value: t.wins), + ], + ), + const SizedBox(height: 24), + ], + ); + }, + ); + } +} + +class _StandingsHeaderRow extends StatelessWidget { + const _StandingsHeaderRow(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final style = theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + letterSpacing: 0.6, + ); + return Padding( + padding: const EdgeInsets.fromLTRB(24, 4, 24, 8), + child: Row( + children: [ + SizedBox(width: 34, child: Text('#', style: style)), + const SizedBox(width: 12), + Expanded(child: Text('TEAM', style: style)), + const SizedBox(width: 12), + SizedBox(width: 70, child: Text('W · D · L', style: style)), + SizedBox( + width: 48, + child: Text( + 'PTS', + style: style, + textAlign: TextAlign.right, + ), + ), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Player leaderboards +// --------------------------------------------------------------------------- + +class _ScorersTab extends ConsumerWidget { + const _ScorersTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final scorersAsync = ref.watch(topScorersProvider); + return scorersAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => _ErrorState( + message: err.toString(), + onRetry: () => ref.invalidate(topScorersProvider), + ), + data: (entries) => _PlayerLeaderboardView( + entries: entries, + statSelector: (p) => p.player.goalsScored, + statLabel: 'goals', + chartLabel: 'Goals', + headerTitle: 'Top scorers', + chartTitle: 'Top 6 scorers', + emptyTitle: 'No goals yet', + emptyBody: 'Player goal tallies will appear here once games are logged.', + ), + ); + } +} + +class _AssistsTab extends ConsumerWidget { + const _AssistsTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final assistsAsync = ref.watch(topAssistersProvider); + return assistsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (err, _) => _ErrorState( + message: err.toString(), + onRetry: () => ref.invalidate(topAssistersProvider), + ), + data: (entries) => _PlayerLeaderboardView( + entries: entries, + statSelector: (p) => p.player.assists, + statLabel: 'assists', + chartLabel: 'Assists', + headerTitle: 'Top assists', + chartTitle: 'Top 6 assist leaders', + emptyTitle: 'No assists yet', + emptyBody: + 'Player assist tallies will appear here once games are logged.', + ), + ); + } +} + +/// Shared layout for the two player-stat tabs: chart on top, ranked list below. +class _PlayerLeaderboardView extends StatelessWidget { + const _PlayerLeaderboardView({ + required this.entries, + required this.statSelector, + required this.statLabel, + required this.chartLabel, + required this.headerTitle, + required this.chartTitle, + required this.emptyTitle, + required this.emptyBody, + }); + + final List entries; + final int Function(PlayerWithTeam) statSelector; + final String statLabel; + final String chartLabel; + final String headerTitle; + final String chartTitle; + final String emptyTitle; + final String emptyBody; + + @override + Widget build(BuildContext context) { + // Drop players who haven't scored anything in the active category so the + // chart and list stay meaningful when the season just started. + final ranked = entries.where((e) => statSelector(e) > 0).toList(); + + if (ranked.isEmpty) { + return _EmptyState( + icon: Icons.bar_chart_outlined, + title: emptyTitle, + body: emptyBody, + ); + } + + final top = ranked.take(6).toList(growable: false); + + return ListView( + padding: const EdgeInsets.symmetric(vertical: 12), + children: [ + _SectionHeader(label: chartTitle), + StatBarChart( + valueLabel: chartLabel, + data: [ + for (final e in top) + StatBarDatum(label: e.player.name, value: statSelector(e)), + ], + ), + const SizedBox(height: 16), + _SectionHeader(label: headerTitle), + for (var i = 0; i < ranked.length; i++) + LeaderboardTile.player( + rank: i + 1, + entry: ranked[i], + statValue: statSelector(ranked[i]), + statLabel: statLabel, + navContext: context, + ), + const SizedBox(height: 24), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Shared bits +// --------------------------------------------------------------------------- + +class _SectionHeader extends StatelessWidget { + const _SectionHeader({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), + child: Text( + label, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState({ + required this.icon, + required this.title, + required this.body, + }); + + final IconData icon; + final String title; + final String body; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 64, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(height: 16), + Text(title, style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + body, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +class _ErrorState extends StatelessWidget { + const _ErrorState({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: theme.colorScheme.error, + ), + const SizedBox(height: 16), + Text('Could not load stats', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + FilledButton.tonalIcon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Try again'), + ), + ], + ), + ), + ); + } +} + diff --git a/lib/features/stats/presentation/widgets/leaderboard_tile.dart b/lib/features/stats/presentation/widgets/leaderboard_tile.dart new file mode 100644 index 0000000..213d85c --- /dev/null +++ b/lib/features/stats/presentation/widgets/leaderboard_tile.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../teams/domain/team.dart'; +import '../../application/stats_notifier.dart'; + +/// Ranked row used in every leaderboard list on the Stats screen. +/// +/// Use either [LeaderboardTile.player] for the player leaderboards or +/// [LeaderboardTile.team] for the league standings — both share the rank +/// medal styling and tap affordances. +class LeaderboardTile extends StatelessWidget { + const LeaderboardTile._({ + required this.rank, + required this.title, + required this.subtitle, + required this.trailingValue, + required this.trailingLabel, + this.onTap, + }); + + /// Player leaderboard variant. Tapping navigates to the player's team page. + factory LeaderboardTile.player({ + Key? key, + required int rank, + required PlayerWithTeam entry, + required int statValue, + required String statLabel, + BuildContext? navContext, + }) { + return LeaderboardTile._( + rank: rank, + title: entry.player.name, + subtitle: entry.team.name, + trailingValue: statValue, + trailingLabel: statLabel, + onTap: navContext == null + ? null + : () => navContext.go('/teams/${entry.team.id}'), + ); + } + + /// Team standings variant. Tapping navigates to the team detail page. + factory LeaderboardTile.team({ + Key? key, + required int rank, + required Team team, + BuildContext? navContext, + }) { + final points = team.wins * 3 + team.draws; + return LeaderboardTile._( + rank: rank, + title: team.name, + subtitle: '${team.wins}W · ${team.draws}D · ${team.losses}L', + trailingValue: points, + trailingLabel: 'pts', + onTap: navContext == null + ? null + : () => navContext.go('/teams/${team.id}'), + ); + } + + final int rank; + final String title; + final String subtitle; + final int trailingValue; + final String trailingLabel; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return Card( + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Row( + children: [ + _RankMedal(rank: rank), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + _TrailingStat(value: trailingValue, label: trailingLabel), + ], + ), + ), + ), + ); + } +} + +class _RankMedal extends StatelessWidget { + const _RankMedal({required this.rank}); + + final int rank; + + // Podium medal colors — only hardcoded colors in the feature. + static const _gold = Color(0xFFFFD700); + static const _silver = Color(0xFFC0C0C0); + static const _bronze = Color(0xFFCD7F32); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final isPodium = rank >= 1 && rank <= 3; + final medalColor = switch (rank) { + 1 => _gold, + 2 => _silver, + 3 => _bronze, + _ => scheme.surfaceContainerHighest, + }; + final textColor = isPodium ? Colors.black : scheme.onSurfaceVariant; + + return Container( + width: 34, + height: 34, + alignment: Alignment.center, + decoration: BoxDecoration( + color: medalColor, + shape: BoxShape.circle, + border: isPodium + ? null + : Border.all(color: scheme.outlineVariant, width: 1), + ), + child: Text( + '$rank', + style: TextStyle( + color: textColor, + fontWeight: FontWeight.w800, + fontSize: 13, + ), + ), + ); + } +} + +class _TrailingStat extends StatelessWidget { + const _TrailingStat({required this.value, required this.label}); + + final int value; + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$value', + style: theme.textTheme.titleMedium?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.w800, + ), + ), + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + letterSpacing: 0.4, + ), + ), + ], + ); + } +} diff --git a/lib/features/stats/presentation/widgets/stat_bar_chart.dart b/lib/features/stats/presentation/widgets/stat_bar_chart.dart new file mode 100644 index 0000000..4fe13ed --- /dev/null +++ b/lib/features/stats/presentation/widgets/stat_bar_chart.dart @@ -0,0 +1,214 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; + +/// A single labelled bar in [StatBarChart]. +class StatBarDatum { + const StatBarDatum({required this.label, required this.value}); + + /// Short label rendered along the X axis (kept terse so it fits). + final String label; + final int value; +} + +/// Lightweight wrapper around [BarChart] for the top-6 leaderboards. Renders +/// vertical bars with a numeric Y axis and the supplied [data] labels on X. +class StatBarChart extends StatelessWidget { + const StatBarChart({ + super.key, + required this.data, + required this.valueLabel, + this.height = 280, + }); + + /// Sorted list of bars to render (highest first); only the first 6 are used. + final List data; + + /// Used for the Y axis title, e.g. "Goals". + final String valueLabel; + + final double height; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + final visible = data.take(6).toList(growable: false); + + if (visible.isEmpty) { + return SizedBox( + height: height, + child: Center( + child: Text( + 'No data yet', + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ), + ); + } + + final maxValue = visible + .map((d) => d.value) + .fold(0, (a, b) => a > b ? a : b); + // Round the y-axis ceiling up to the nearest sensible interval so the + // grid lines land on whole numbers. + final yMax = maxValue <= 4 ? 4.0 : (maxValue + 2).toDouble(); + final interval = yMax <= 6 ? 1.0 : (yMax / 5).ceilToDouble(); + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 16, 8), + child: SizedBox( + height: height, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: yMax, + minY: 0, + barTouchData: BarTouchData( + enabled: true, + touchTooltipData: BarTouchTooltipData( + getTooltipColor: (_) => scheme.surfaceContainerHigh, + tooltipBorder: BorderSide(color: scheme.outlineVariant), + getTooltipItem: (group, _, rod, _) { + final datum = visible[group.x]; + return BarTooltipItem( + '${datum.label}\n', + theme.textTheme.bodySmall!.copyWith( + color: scheme.onSurface, + fontWeight: FontWeight.w700, + ), + children: [ + TextSpan( + text: '${rod.toY.toInt()} $valueLabel', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ], + ); + }, + ), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: interval, + getDrawingHorizontalLine: (_) => FlLine( + color: scheme.outlineVariant.withValues(alpha: 0.4), + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + show: true, + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + leftTitles: AxisTitles( + axisNameWidget: Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text( + valueLabel, + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + letterSpacing: 0.5, + ), + ), + ), + axisNameSize: 18, + sideTitles: SideTitles( + showTitles: true, + interval: interval, + reservedSize: 32, + getTitlesWidget: (value, meta) { + if (value == 0 || value > yMax) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(right: 4), + child: Text( + value.toInt().toString(), + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + interval: 1, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index < 0 || index >= visible.length) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _shortLabel(visible[index].label), + textAlign: TextAlign.center, + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ); + }, + ), + ), + ), + barGroups: [ + for (var i = 0; i < visible.length; i++) + BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: visible[i].value.toDouble(), + color: scheme.primary, + width: 22, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(6), + ), + backDrawRodData: BackgroundBarChartRodData( + show: true, + toY: yMax, + color: scheme.surfaceContainerHighest + .withValues(alpha: 0.5), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// Compresses long names like "Marcus Reed" → "M. Reed" so X-axis labels + /// don't overflow on phone widths. + static String _shortLabel(String full) { + final parts = full.trim().split(RegExp(r'\s+')); + if (parts.length < 2) { + return parts.first.length > 10 + ? '${parts.first.substring(0, 9)}…' + : parts.first; + } + final last = parts.last; + final first = parts.first; + final initial = first.isEmpty ? '' : '${first[0]}. '; + final candidate = '$initial$last'; + if (candidate.length <= 12) return candidate; + return '${candidate.substring(0, 11)}…'; + } +} diff --git a/lib/features/stats/presentation/widgets/stats_filter_bar.dart b/lib/features/stats/presentation/widgets/stats_filter_bar.dart new file mode 100644 index 0000000..fa22a0e --- /dev/null +++ b/lib/features/stats/presentation/widgets/stats_filter_bar.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +/// Top tab strip for the Stats screen. Hosted inside an [AppBar] `bottom:` +/// slot and driven by an ambient [DefaultTabController]. +class StatsFilterBar extends StatelessWidget implements PreferredSizeWidget { + const StatsFilterBar({super.key}); + + static const double _height = 48; + + @override + Size get preferredSize => const Size.fromHeight(_height); + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return TabBar( + isScrollable: false, + labelColor: scheme.primary, + unselectedLabelColor: scheme.onSurfaceVariant, + indicatorColor: scheme.primary, + indicatorSize: TabBarIndicatorSize.label, + labelStyle: const TextStyle(fontWeight: FontWeight.w700), + tabs: const [ + Tab(text: 'Standings'), + Tab(text: 'Top Scorers'), + Tab(text: 'Top Assists'), + ], + ); + } +} diff --git a/lib/features/suggestions/application/suggestions_notifier.dart b/lib/features/suggestions/application/suggestions_notifier.dart new file mode 100644 index 0000000..d41ad6a --- /dev/null +++ b/lib/features/suggestions/application/suggestions_notifier.dart @@ -0,0 +1,58 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../auth/application/auth_notifier.dart'; +import '../domain/suggestion.dart'; +import '../infrastructure/suggestions_repository.dart'; + +part 'suggestions_notifier.g.dart'; + +/// Tracks the submission lifecycle of the suggestion form. +/// +/// State is an `AsyncValue`: +/// * idle → `AsyncData(null)` after [build] +/// * busy → `AsyncLoading()` while a submit is in flight +/// * done → `AsyncData(null)` after a successful submit +/// * error → `AsyncError(...)` on failure +@riverpod +class SuggestionsNotifier extends _$SuggestionsNotifier { + @override + Future build() async { + return; + } + + /// Submits a suggestion. UI should already have validated [text] length + /// before calling — this method does not re-validate. + Future submit({ + required String text, + required bool isAnonymous, + String? userId, + String? displayName, + }) async { + final repo = ref.read(suggestionsRepositoryProvider); + state = const AsyncLoading(); + state = await AsyncValue.guard(() async { + await repo.submitSuggestion( + text: text.trim(), + isAnonymous: isAnonymous, + userId: isAnonymous ? null : userId, + displayName: isAnonymous ? null : displayName, + ); + }); + } +} + +/// Streams the current user's previously-submitted suggestions. +/// +/// Emits an empty list when the user is signed out, so the UI can render a +/// stable widget tree without juggling auth-vs-stream loading states. +@riverpod +Stream> userSuggestions(UserSuggestionsRef ref) async* { + final auth = ref.watch(authNotifierProvider); + final user = auth.valueOrNull; + if (user == null) { + yield []; + return; + } + final repo = ref.watch(suggestionsRepositoryProvider); + yield* repo.watchUserSuggestions(user.uid); +} diff --git a/lib/features/suggestions/application/suggestions_notifier.g.dart b/lib/features/suggestions/application/suggestions_notifier.g.dart new file mode 100644 index 0000000..843682f --- /dev/null +++ b/lib/features/suggestions/application/suggestions_notifier.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'suggestions_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$userSuggestionsHash() => r'8544dca51c0cb3453bfc7219fde2ec43e55b3106'; + +/// Streams the current user's previously-submitted suggestions. +/// +/// Emits an empty list when the user is signed out, so the UI can render a +/// stable widget tree without juggling auth-vs-stream loading states. +/// +/// Copied from [userSuggestions]. +@ProviderFor(userSuggestions) +final userSuggestionsProvider = + AutoDisposeStreamProvider>.internal( + userSuggestions, + name: r'userSuggestionsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$userSuggestionsHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef UserSuggestionsRef = AutoDisposeStreamProviderRef>; +String _$suggestionsNotifierHash() => + r'f7a4d35220e955e11bbd10872c8e2d838cc1a3a7'; + +/// Tracks the submission lifecycle of the suggestion form. +/// +/// State is an `AsyncValue`: +/// * idle → `AsyncData(null)` after [build] +/// * busy → `AsyncLoading()` while a submit is in flight +/// * done → `AsyncData(null)` after a successful submit +/// * error → `AsyncError(...)` on failure +/// +/// Copied from [SuggestionsNotifier]. +@ProviderFor(SuggestionsNotifier) +final suggestionsNotifierProvider = + AutoDisposeAsyncNotifierProvider.internal( + SuggestionsNotifier.new, + name: r'suggestionsNotifierProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$suggestionsNotifierHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$SuggestionsNotifier = AutoDisposeAsyncNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/suggestions/domain/suggestion.dart b/lib/features/suggestions/domain/suggestion.dart new file mode 100644 index 0000000..93ec4f1 --- /dev/null +++ b/lib/features/suggestions/domain/suggestion.dart @@ -0,0 +1,108 @@ +enum SuggestionStatus { pending, reviewed, implemented } + +class Suggestion { + const Suggestion({ + required this.id, + required this.text, + required this.isAnonymous, + required this.submittedAt, + required this.status, + this.userId, + this.displayName, + }); + + final String id; + final String text; + final bool isAnonymous; + final String? userId; + final String? displayName; + final DateTime submittedAt; + final SuggestionStatus status; + + Suggestion copyWith({ + String? id, + String? text, + bool? isAnonymous, + String? userId, + String? displayName, + DateTime? submittedAt, + SuggestionStatus? status, + }) { + return Suggestion( + id: id ?? this.id, + text: text ?? this.text, + isAnonymous: isAnonymous ?? this.isAnonymous, + userId: userId ?? this.userId, + displayName: displayName ?? this.displayName, + submittedAt: submittedAt ?? this.submittedAt, + status: status ?? this.status, + ); + } + + factory Suggestion.fromJson(Map data) { + return Suggestion( + id: (data['id'] as String?) ?? '', + text: (data['text'] as String?) ?? '', + isAnonymous: _parseBool(data['is_anonymous']), + userId: data['user_id'] as String?, + displayName: data['display_name'] as String?, + submittedAt: _parseDate(data['submitted_at']) ?? DateTime.now(), + status: _parseStatus(data['status'] as String?), + ); + } + + Map toJson() { + return { + 'text': text, + 'is_anonymous': isAnonymous, + 'user_id': isAnonymous ? null : userId, + 'display_name': isAnonymous ? null : displayName, + 'status': status.name, + }; + } + + static DateTime? _parseDate(Object? v) { + if (v is String && v.isNotEmpty) return DateTime.tryParse(v); + return null; + } + + static bool _parseBool(Object? v) { + if (v is bool) return v; + if (v is int) return v != 0; + if (v is String) return v == '1' || v.toLowerCase() == 'true'; + return false; + } + + static SuggestionStatus _parseStatus(String? raw) { + switch (raw) { + case 'reviewed': + return SuggestionStatus.reviewed; + case 'implemented': + return SuggestionStatus.implemented; + default: + return SuggestionStatus.pending; + } + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Suggestion && + other.id == id && + other.text == text && + other.isAnonymous == isAnonymous && + other.userId == userId && + other.displayName == displayName && + other.submittedAt == submittedAt && + other.status == status; + } + + @override + int get hashCode => Object.hash( + id, text, isAnonymous, userId, displayName, submittedAt, status, + ); + + @override + String toString() => + 'Suggestion(id: $id, status: ${status.name}, anonymous: $isAnonymous)'; +} diff --git a/lib/features/suggestions/infrastructure/suggestions_repository.dart b/lib/features/suggestions/infrastructure/suggestions_repository.dart new file mode 100644 index 0000000..ced8b47 --- /dev/null +++ b/lib/features/suggestions/infrastructure/suggestions_repository.dart @@ -0,0 +1,68 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/api/api_client.dart'; +import '../domain/suggestion.dart'; + +part 'suggestions_repository.g.dart'; + +class SuggestionsRepository { + SuggestionsRepository(this._api); + + final ApiClient _api; + + Future submitSuggestion({ + required String text, + required bool isAnonymous, + String? userId, + String? displayName, + }) async { + await _api.post('/suggestions/index.php', { + 'text': text, + 'is_anonymous': isAnonymous, + 'display_name': displayName ?? '', + }); + } + + Future> fetchUserSuggestions() async { + final data = await _api.get('/suggestions/index.php'); + final list = (data['suggestions'] as List?) ?? []; + return list.whereType>().map(Suggestion.fromJson).toList(); + } + + Future> fetchAllSuggestions() async { + final data = await _api.get('/suggestions/index.php'); + final list = (data['suggestions'] as List?) ?? []; + return list.whereType>().map(Suggestion.fromJson).toList(); + } + + Future updateStatus(String id, SuggestionStatus status) async { + await _api.put( + '/suggestions/detail.php', + {'status': status.name}, + params: {'id': id}, + ); + } + + Future deleteSuggestion(String id) async { + await _api.delete('/suggestions/detail.php', params: {'id': id}); + } + + Stream> watchUserSuggestions(String userId) async* { + yield await fetchUserSuggestions(); + await for (final _ in Stream.periodic(const Duration(seconds: 30))) { + yield await fetchUserSuggestions(); + } + } + + Stream> watchAllSuggestions() async* { + yield await fetchAllSuggestions(); + await for (final _ in Stream.periodic(const Duration(seconds: 30))) { + yield await fetchAllSuggestions(); + } + } +} + +@Riverpod(keepAlive: true) +SuggestionsRepository suggestionsRepository(SuggestionsRepositoryRef ref) { + return SuggestionsRepository(ref.watch(apiClientProvider)); +} diff --git a/lib/features/suggestions/infrastructure/suggestions_repository.g.dart b/lib/features/suggestions/infrastructure/suggestions_repository.g.dart new file mode 100644 index 0000000..83c3649 --- /dev/null +++ b/lib/features/suggestions/infrastructure/suggestions_repository.g.dart @@ -0,0 +1,28 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'suggestions_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$suggestionsRepositoryHash() => + r'5cf92a23c07a7d135224b0fcd2831f68f4a9a27f'; + +/// See also [suggestionsRepository]. +@ProviderFor(suggestionsRepository) +final suggestionsRepositoryProvider = Provider.internal( + suggestionsRepository, + name: r'suggestionsRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$suggestionsRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef SuggestionsRepositoryRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/suggestions/presentation/suggestions_screen.dart b/lib/features/suggestions/presentation/suggestions_screen.dart new file mode 100644 index 0000000..79c8562 --- /dev/null +++ b/lib/features/suggestions/presentation/suggestions_screen.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../auth/application/auth_notifier.dart'; +import '../application/suggestions_notifier.dart'; +import 'widgets/suggestion_form.dart'; +import 'widgets/suggestion_list_tile.dart'; + +/// Top-level Suggestions screen. +/// +/// Top half is the always-visible [SuggestionForm]. Bottom half lists the +/// signed-in user's past suggestions via [userSuggestionsProvider], or a +/// gentle sign-in prompt when there's no current user. +class SuggestionsScreen extends ConsumerWidget { + const SuggestionsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final authUser = ref.watch(authNotifierProvider).valueOrNull; + final suggestionsAsync = ref.watch(userSuggestionsProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Suggestions')), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SuggestionForm(), + const SizedBox(height: 24), + const Divider(), + const SizedBox(height: 16), + Text( + 'Your Suggestions', + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 12), + if (authUser == null) + _SignInPrompt(colors: colors, textTheme: theme.textTheme) + else + suggestionsAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: CircularProgressIndicator()), + ), + error: (err, _) => _ErrorState( + message: 'Could not load your suggestions.', + detail: '$err', + colors: colors, + textTheme: theme.textTheme, + ), + data: (suggestions) { + if (suggestions.isEmpty) { + return _EmptyState( + colors: colors, + textTheme: theme.textTheme, + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final s in suggestions) + SuggestionListTile(suggestion: s), + ], + ); + }, + ), + ], + ), + ), + ), + ); + } +} + +class _SignInPrompt extends StatelessWidget { + const _SignInPrompt({required this.colors, required this.textTheme}); + + final ColorScheme colors; + final TextTheme textTheme; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(Icons.lock_outline, color: colors.onSurfaceVariant), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Sign in to view your past suggestions.', + style: textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ], + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState({required this.colors, required this.textTheme}); + + final ColorScheme colors; + final TextTheme textTheme; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + children: [ + Icon( + Icons.lightbulb_outline, + size: 36, + color: colors.onSurfaceVariant, + ), + const SizedBox(height: 8), + Text( + 'No suggestions yet — share your first idea above.', + textAlign: TextAlign.center, + style: textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ); + } +} + +class _ErrorState extends StatelessWidget { + const _ErrorState({ + required this.message, + required this.detail, + required this.colors, + required this.textTheme, + }); + + final String message; + final String detail; + final ColorScheme colors; + final TextTheme textTheme; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: colors.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.error_outline, color: colors.onErrorContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: textTheme.bodyMedium?.copyWith( + color: colors.onErrorContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + detail, + style: textTheme.bodySmall?.copyWith( + color: colors.onErrorContainer, + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/suggestions/presentation/widgets/suggestion_form.dart b/lib/features/suggestions/presentation/widgets/suggestion_form.dart new file mode 100644 index 0000000..d21ad57 --- /dev/null +++ b/lib/features/suggestions/presentation/widgets/suggestion_form.dart @@ -0,0 +1,188 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../auth/application/auth_notifier.dart'; +import '../../application/suggestions_notifier.dart'; + +/// Form for submitting a new community suggestion. +/// +/// Owns its own [TextEditingController] and the "anonymous" toggle. Wires +/// into [suggestionsNotifierProvider] for submission state — the FilledButton +/// switches to a spinner while loading, and shows snackbars on success/error. +class SuggestionForm extends ConsumerStatefulWidget { + const SuggestionForm({super.key}); + + static const int _maxChars = 500; + static const int _minChars = 10; + + @override + ConsumerState createState() => _SuggestionFormState(); +} + +class _SuggestionFormState extends ConsumerState { + final _formKey = GlobalKey(); + final _controller = TextEditingController(); + bool _isAnonymous = false; + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + String? _validate(String? value) { + final text = value?.trim() ?? ''; + if (text.length < SuggestionForm._minChars) { + return 'Please write at least ${SuggestionForm._minChars} characters.'; + } + return null; + } + + Future _onSubmit() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + FocusScope.of(context).unfocus(); + + final user = ref.read(authNotifierProvider).valueOrNull; + final messenger = ScaffoldMessenger.of(context); + + await ref.read(suggestionsNotifierProvider.notifier).submit( + text: _controller.text, + isAnonymous: _isAnonymous, + userId: user?.uid, + displayName: user?.displayName, + ); + + if (!mounted) return; + + final state = ref.read(suggestionsNotifierProvider); + state.when( + data: (_) { + _controller.clear(); + _formKey.currentState?.reset(); + setState(() {}); + messenger.showSnackBar( + const SnackBar(content: Text('Thanks for your suggestion!')), + ); + }, + loading: () {}, + error: (err, _) { + messenger.showSnackBar( + SnackBar( + content: Text('Could not submit suggestion: $err'), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final submissionState = ref.watch(suggestionsNotifierProvider); + final isSubmitting = submissionState.isLoading; + final authUser = ref.watch(authNotifierProvider).valueOrNull; + + final submittingAs = (!_isAnonymous && authUser != null) + ? (authUser.displayName?.trim().isNotEmpty ?? false + ? authUser.displayName!.trim() + : authUser.email) + : null; + + return Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Share an idea', + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 4), + Text( + 'Tell us what would make Winded better — new features, ' + 'tournament formats, anything.', + style: theme.textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _controller, + enabled: !isSubmitting, + minLines: 3, + maxLines: 8, + maxLength: SuggestionForm._maxChars, + textInputAction: TextInputAction.newline, + keyboardType: TextInputType.multiline, + inputFormatters: [ + LengthLimitingTextInputFormatter(SuggestionForm._maxChars), + ], + validator: _validate, + autovalidateMode: AutovalidateMode.onUserInteraction, + onChanged: (_) => setState(() {}), + decoration: const InputDecoration( + hintText: 'Type your suggestion here…', + border: OutlineInputBorder(), + alignLabelWithHint: true, + ), + ), + const SizedBox(height: 8), + SwitchListTile.adaptive( + value: _isAnonymous, + onChanged: isSubmitting + ? null + : (value) => setState(() => _isAnonymous = value), + contentPadding: EdgeInsets.zero, + title: const Text('Submit anonymously'), + subtitle: Text( + _isAnonymous + ? 'Your name will not be attached to this suggestion.' + : 'Admins will see who submitted this.', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + if (submittingAs != null) ...[ + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.person_outline, + size: 16, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Submitting as: $submittingAs', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ], + ), + ], + const SizedBox(height: 16), + FilledButton.icon( + onPressed: isSubmitting ? null : _onSubmit, + icon: isSubmitting + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colors.onPrimary, + ), + ) + : const Icon(Icons.send_outlined), + label: Text(isSubmitting ? 'Submitting…' : 'Submit Suggestion'), + ), + ], + ), + ); + } +} diff --git a/lib/features/suggestions/presentation/widgets/suggestion_list_tile.dart b/lib/features/suggestions/presentation/widgets/suggestion_list_tile.dart new file mode 100644 index 0000000..45ebe77 --- /dev/null +++ b/lib/features/suggestions/presentation/widgets/suggestion_list_tile.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../domain/suggestion.dart'; + +/// Card-style row showing one of the current user's past suggestions. +/// +/// Truncates the body to three lines, prints a friendly relative-ish date, +/// and renders a status chip whose color matches the suggestion lifecycle. +class SuggestionListTile extends StatelessWidget { + const SuggestionListTile({super.key, required this.suggestion}); + + final Suggestion suggestion; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final dateLabel = + DateFormat.yMMMd().add_jm().format(suggestion.submittedAt); + + return Card( + margin: const EdgeInsets.symmetric(vertical: 6), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + suggestion.text, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge, + ), + ), + const SizedBox(width: 8), + _StatusChip(status: suggestion.status), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Icon( + Icons.schedule, + size: 14, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + dateLabel, + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + if (suggestion.isAnonymous) ...[ + const SizedBox(width: 12), + Icon( + Icons.visibility_off_outlined, + size: 14, + color: colors.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + 'Anonymous', + style: theme.textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ], + ), + ], + ), + ), + ); + } +} + +class _StatusChip extends StatelessWidget { + const _StatusChip({required this.status}); + + final SuggestionStatus status; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + + final (Color background, Color foreground, String label) = switch (status) { + SuggestionStatus.pending => ( + colors.surfaceContainerHighest, + colors.onSurfaceVariant, + 'Pending', + ), + SuggestionStatus.reviewed => ( + Colors.amber.withValues(alpha: 0.18), + Colors.amber.shade300, + 'Reviewed', + ), + SuggestionStatus.implemented => ( + Colors.green.withValues(alpha: 0.18), + Colors.green.shade300, + 'Implemented', + ), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/lib/features/teams/application/teams_notifier.dart b/lib/features/teams/application/teams_notifier.dart new file mode 100644 index 0000000..a99a4ff --- /dev/null +++ b/lib/features/teams/application/teams_notifier.dart @@ -0,0 +1,40 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../domain/join_request.dart'; +import '../domain/team.dart'; +import '../infrastructure/teams_repository.dart'; + +part 'teams_notifier.g.dart'; + +/// Resolves a single [Team] by id out of the teams stream. Returns null while +/// loading or if no team matches. +@riverpod +Team? teamById(TeamByIdRef ref, String id) { + final teams = ref.watch(teamsStreamProvider).valueOrNull; + if (teams == null) return null; + for (final team in teams) { + if (team.id == id) return team; + } + return null; +} + +/// Streams every join request for [teamId]. Used by the manager dashboard. +@riverpod +Stream> joinRequestsForTeam( + JoinRequestsForTeamRef ref, + String teamId, +) { + return ref.watch(teamsRepositoryProvider).watchJoinRequestsForTeam(teamId); +} + +/// Streams every join request submitted by [playerId]. Used to decide +/// whether to show "Request pending" on a team detail page. +@riverpod +Stream> joinRequestsForPlayer( + JoinRequestsForPlayerRef ref, + String playerId, +) { + return ref + .watch(teamsRepositoryProvider) + .watchJoinRequestsForPlayer(playerId); +} diff --git a/lib/features/teams/application/teams_notifier.g.dart b/lib/features/teams/application/teams_notifier.g.dart new file mode 100644 index 0000000..b7881b0 --- /dev/null +++ b/lib/features/teams/application/teams_notifier.g.dart @@ -0,0 +1,442 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'teams_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$teamByIdHash() => r'321ea04a62f6a3e9788f820c36d7d6bea6bc968f'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// Resolves a single [Team] by id out of the teams stream. Returns null while +/// loading or if no team matches. +/// +/// Copied from [teamById]. +@ProviderFor(teamById) +const teamByIdProvider = TeamByIdFamily(); + +/// Resolves a single [Team] by id out of the teams stream. Returns null while +/// loading or if no team matches. +/// +/// Copied from [teamById]. +class TeamByIdFamily extends Family { + /// Resolves a single [Team] by id out of the teams stream. Returns null while + /// loading or if no team matches. + /// + /// Copied from [teamById]. + const TeamByIdFamily(); + + /// Resolves a single [Team] by id out of the teams stream. Returns null while + /// loading or if no team matches. + /// + /// Copied from [teamById]. + TeamByIdProvider call(String id) { + return TeamByIdProvider(id); + } + + @override + TeamByIdProvider getProviderOverride(covariant TeamByIdProvider provider) { + return call(provider.id); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'teamByIdProvider'; +} + +/// Resolves a single [Team] by id out of the teams stream. Returns null while +/// loading or if no team matches. +/// +/// Copied from [teamById]. +class TeamByIdProvider extends AutoDisposeProvider { + /// Resolves a single [Team] by id out of the teams stream. Returns null while + /// loading or if no team matches. + /// + /// Copied from [teamById]. + TeamByIdProvider(String id) + : this._internal( + (ref) => teamById(ref as TeamByIdRef, id), + from: teamByIdProvider, + name: r'teamByIdProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$teamByIdHash, + dependencies: TeamByIdFamily._dependencies, + allTransitiveDependencies: TeamByIdFamily._allTransitiveDependencies, + id: id, + ); + + TeamByIdProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.id, + }) : super.internal(); + + final String id; + + @override + Override overrideWith(Team? Function(TeamByIdRef provider) create) { + return ProviderOverride( + origin: this, + override: TeamByIdProvider._internal( + (ref) => create(ref as TeamByIdRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + id: id, + ), + ); + } + + @override + AutoDisposeProviderElement createElement() { + return _TeamByIdProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is TeamByIdProvider && other.id == id; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, id.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin TeamByIdRef on AutoDisposeProviderRef { + /// The parameter `id` of this provider. + String get id; +} + +class _TeamByIdProviderElement extends AutoDisposeProviderElement + with TeamByIdRef { + _TeamByIdProviderElement(super.provider); + + @override + String get id => (origin as TeamByIdProvider).id; +} + +String _$joinRequestsForTeamHash() => + r'fd951881199d04c8ca5a7be49aef3bb3faccb76d'; + +/// Streams every join request for [teamId]. Used by the manager dashboard. +/// +/// Copied from [joinRequestsForTeam]. +@ProviderFor(joinRequestsForTeam) +const joinRequestsForTeamProvider = JoinRequestsForTeamFamily(); + +/// Streams every join request for [teamId]. Used by the manager dashboard. +/// +/// Copied from [joinRequestsForTeam]. +class JoinRequestsForTeamFamily extends Family>> { + /// Streams every join request for [teamId]. Used by the manager dashboard. + /// + /// Copied from [joinRequestsForTeam]. + const JoinRequestsForTeamFamily(); + + /// Streams every join request for [teamId]. Used by the manager dashboard. + /// + /// Copied from [joinRequestsForTeam]. + JoinRequestsForTeamProvider call(String teamId) { + return JoinRequestsForTeamProvider(teamId); + } + + @override + JoinRequestsForTeamProvider getProviderOverride( + covariant JoinRequestsForTeamProvider provider, + ) { + return call(provider.teamId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'joinRequestsForTeamProvider'; +} + +/// Streams every join request for [teamId]. Used by the manager dashboard. +/// +/// Copied from [joinRequestsForTeam]. +class JoinRequestsForTeamProvider + extends AutoDisposeStreamProvider> { + /// Streams every join request for [teamId]. Used by the manager dashboard. + /// + /// Copied from [joinRequestsForTeam]. + JoinRequestsForTeamProvider(String teamId) + : this._internal( + (ref) => joinRequestsForTeam(ref as JoinRequestsForTeamRef, teamId), + from: joinRequestsForTeamProvider, + name: r'joinRequestsForTeamProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$joinRequestsForTeamHash, + dependencies: JoinRequestsForTeamFamily._dependencies, + allTransitiveDependencies: + JoinRequestsForTeamFamily._allTransitiveDependencies, + teamId: teamId, + ); + + JoinRequestsForTeamProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.teamId, + }) : super.internal(); + + final String teamId; + + @override + Override overrideWith( + Stream> Function(JoinRequestsForTeamRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: JoinRequestsForTeamProvider._internal( + (ref) => create(ref as JoinRequestsForTeamRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + teamId: teamId, + ), + ); + } + + @override + AutoDisposeStreamProviderElement> createElement() { + return _JoinRequestsForTeamProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is JoinRequestsForTeamProvider && other.teamId == teamId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, teamId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin JoinRequestsForTeamRef + on AutoDisposeStreamProviderRef> { + /// The parameter `teamId` of this provider. + String get teamId; +} + +class _JoinRequestsForTeamProviderElement + extends AutoDisposeStreamProviderElement> + with JoinRequestsForTeamRef { + _JoinRequestsForTeamProviderElement(super.provider); + + @override + String get teamId => (origin as JoinRequestsForTeamProvider).teamId; +} + +String _$joinRequestsForPlayerHash() => + r'47ea047439ef88b65daee31c4e108ed6a805adf6'; + +/// Streams every join request submitted by [playerId]. Used to decide +/// whether to show "Request pending" on a team detail page. +/// +/// Copied from [joinRequestsForPlayer]. +@ProviderFor(joinRequestsForPlayer) +const joinRequestsForPlayerProvider = JoinRequestsForPlayerFamily(); + +/// Streams every join request submitted by [playerId]. Used to decide +/// whether to show "Request pending" on a team detail page. +/// +/// Copied from [joinRequestsForPlayer]. +class JoinRequestsForPlayerFamily + extends Family>> { + /// Streams every join request submitted by [playerId]. Used to decide + /// whether to show "Request pending" on a team detail page. + /// + /// Copied from [joinRequestsForPlayer]. + const JoinRequestsForPlayerFamily(); + + /// Streams every join request submitted by [playerId]. Used to decide + /// whether to show "Request pending" on a team detail page. + /// + /// Copied from [joinRequestsForPlayer]. + JoinRequestsForPlayerProvider call(String playerId) { + return JoinRequestsForPlayerProvider(playerId); + } + + @override + JoinRequestsForPlayerProvider getProviderOverride( + covariant JoinRequestsForPlayerProvider provider, + ) { + return call(provider.playerId); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'joinRequestsForPlayerProvider'; +} + +/// Streams every join request submitted by [playerId]. Used to decide +/// whether to show "Request pending" on a team detail page. +/// +/// Copied from [joinRequestsForPlayer]. +class JoinRequestsForPlayerProvider + extends AutoDisposeStreamProvider> { + /// Streams every join request submitted by [playerId]. Used to decide + /// whether to show "Request pending" on a team detail page. + /// + /// Copied from [joinRequestsForPlayer]. + JoinRequestsForPlayerProvider(String playerId) + : this._internal( + (ref) => + joinRequestsForPlayer(ref as JoinRequestsForPlayerRef, playerId), + from: joinRequestsForPlayerProvider, + name: r'joinRequestsForPlayerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$joinRequestsForPlayerHash, + dependencies: JoinRequestsForPlayerFamily._dependencies, + allTransitiveDependencies: + JoinRequestsForPlayerFamily._allTransitiveDependencies, + playerId: playerId, + ); + + JoinRequestsForPlayerProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.playerId, + }) : super.internal(); + + final String playerId; + + @override + Override overrideWith( + Stream> Function(JoinRequestsForPlayerRef provider) + create, + ) { + return ProviderOverride( + origin: this, + override: JoinRequestsForPlayerProvider._internal( + (ref) => create(ref as JoinRequestsForPlayerRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + playerId: playerId, + ), + ); + } + + @override + AutoDisposeStreamProviderElement> createElement() { + return _JoinRequestsForPlayerProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is JoinRequestsForPlayerProvider && other.playerId == playerId; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, playerId.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin JoinRequestsForPlayerRef + on AutoDisposeStreamProviderRef> { + /// The parameter `playerId` of this provider. + String get playerId; +} + +class _JoinRequestsForPlayerProviderElement + extends AutoDisposeStreamProviderElement> + with JoinRequestsForPlayerRef { + _JoinRequestsForPlayerProviderElement(super.provider); + + @override + String get playerId => (origin as JoinRequestsForPlayerProvider).playerId; +} + +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/teams/domain/join_request.dart b/lib/features/teams/domain/join_request.dart new file mode 100644 index 0000000..3d42d4e --- /dev/null +++ b/lib/features/teams/domain/join_request.dart @@ -0,0 +1,111 @@ +enum JoinRequestStatus { pending, approved, rejected } + +JoinRequestStatus joinRequestStatusFromString(String? raw) { + switch (raw) { + case 'approved': + return JoinRequestStatus.approved; + case 'rejected': + return JoinRequestStatus.rejected; + default: + return JoinRequestStatus.pending; + } +} + +class JoinRequest { + const JoinRequest({ + required this.id, + required this.teamId, + required this.teamName, + required this.playerId, + required this.playerName, + required this.playerEmail, + required this.status, + required this.requestedAt, + }); + + final String id; + final String teamId; + final String teamName; + final String playerId; + final String playerName; + final String playerEmail; + final JoinRequestStatus status; + final DateTime requestedAt; + + JoinRequest copyWith({ + String? id, + String? teamId, + String? teamName, + String? playerId, + String? playerName, + String? playerEmail, + JoinRequestStatus? status, + DateTime? requestedAt, + }) { + return JoinRequest( + id: id ?? this.id, + teamId: teamId ?? this.teamId, + teamName: teamName ?? this.teamName, + playerId: playerId ?? this.playerId, + playerName: playerName ?? this.playerName, + playerEmail: playerEmail ?? this.playerEmail, + status: status ?? this.status, + requestedAt: requestedAt ?? this.requestedAt, + ); + } + + factory JoinRequest.fromJson(Map data) { + return JoinRequest( + id: (data['id'] as String?) ?? '', + teamId: (data['team_id'] as String?) ?? '', + teamName: (data['team_name'] as String?) ?? '', + playerId: (data['player_id'] as String?) ?? '', + playerName: (data['player_name'] as String?) ?? '', + playerEmail: (data['player_email'] as String?) ?? '', + status: joinRequestStatusFromString(data['status'] as String?), + requestedAt: _parseDate(data['requested_at']) ?? DateTime.now(), + ); + } + + Map toJson() { + return { + 'team_id': teamId, + 'team_name': teamName, + 'player_id': playerId, + 'player_name': playerName, + 'player_email': playerEmail, + 'status': status.name, + 'requested_at': requestedAt.toIso8601String(), + }; + } + + static DateTime? _parseDate(Object? v) { + if (v is String && v.isNotEmpty) return DateTime.tryParse(v); + return null; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is JoinRequest && + other.id == id && + other.teamId == teamId && + other.teamName == teamName && + other.playerId == playerId && + other.playerName == playerName && + other.playerEmail == playerEmail && + other.status == status && + other.requestedAt == requestedAt; + } + + @override + int get hashCode => Object.hash( + id, teamId, teamName, playerId, + playerName, playerEmail, status, requestedAt, + ); + + @override + String toString() => + 'JoinRequest(id: $id, team: $teamName, player: $playerName, ' + 'status: ${status.name})'; +} diff --git a/lib/features/teams/domain/player.dart b/lib/features/teams/domain/player.dart new file mode 100644 index 0000000..076c409 --- /dev/null +++ b/lib/features/teams/domain/player.dart @@ -0,0 +1,84 @@ +class Player { + const Player({ + required this.id, + required this.name, + this.position, + this.avatarUrl, + this.jerseyNumber, + this.goalsScored = 0, + this.assists = 0, + }); + + final String id; + final String name; + final String? position; + final String? avatarUrl; + final int? jerseyNumber; + final int goalsScored; + final int assists; + + Player copyWith({ + String? id, + String? name, + String? position, + String? avatarUrl, + int? jerseyNumber, + int? goalsScored, + int? assists, + }) { + return Player( + id: id ?? this.id, + name: name ?? this.name, + position: position ?? this.position, + avatarUrl: avatarUrl ?? this.avatarUrl, + jerseyNumber: jerseyNumber ?? this.jerseyNumber, + goalsScored: goalsScored ?? this.goalsScored, + assists: assists ?? this.assists, + ); + } + + factory Player.fromMap(Map data) { + return Player( + id: (data['id'] as String?) ?? '', + name: (data['name'] as String?) ?? '', + position: data['position'] as String?, + avatarUrl: data['avatar_url'] as String?, + jerseyNumber: (data['jersey_number'] as num?)?.toInt(), + goalsScored: (data['goals_scored'] as num?)?.toInt() ?? 0, + assists: (data['assists'] as num?)?.toInt() ?? 0, + ); + } + + Map toMap() { + return { + 'id': id, + 'name': name, + 'position': position, + 'avatar_url': avatarUrl, + 'jersey_number': jerseyNumber, + 'goals_scored': goalsScored, + 'assists': assists, + }; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is Player && + other.id == id && + other.name == name && + other.position == position && + other.avatarUrl == avatarUrl && + other.jerseyNumber == jerseyNumber && + other.goalsScored == goalsScored && + other.assists == assists; + } + + @override + int get hashCode => Object.hash( + id, name, position, avatarUrl, jerseyNumber, goalsScored, assists, + ); + + @override + String toString() => 'Player(id: $id, name: $name)'; +} diff --git a/lib/features/teams/domain/team.dart b/lib/features/teams/domain/team.dart new file mode 100644 index 0000000..be72f61 --- /dev/null +++ b/lib/features/teams/domain/team.dart @@ -0,0 +1,174 @@ +import 'player.dart'; + +class TeamStatus { + TeamStatus._(); + static const String pending = 'pending'; + static const String approved = 'approved'; + static const String rejected = 'rejected'; + + static String normalize(String? raw) { + switch (raw) { + case pending: + case approved: + case rejected: + return raw!; + default: + return approved; + } + } +} + +class Team { + const Team({ + required this.id, + required this.name, + this.logoUrl, + this.description, + this.wins = 0, + this.losses = 0, + this.draws = 0, + this.players = const [], + this.primaryColor, + this.managerId, + this.managerEmail = '', + this.managerPhone, + this.status = TeamStatus.approved, + }); + + final String id; + final String name; + final String? logoUrl; + final String? description; + final int wins; + final int losses; + final int draws; + final List players; + final String? primaryColor; + final String? managerId; + final String managerEmail; + final String? managerPhone; + final String status; + + bool get isApproved => status == TeamStatus.approved; + bool get isPending => status == TeamStatus.pending; + bool get isRejected => status == TeamStatus.rejected; + + int get totalGames => wins + losses + draws; + String get record => '$wins-$losses-$draws'; + double get winPercentage => totalGames == 0 ? 0 : wins / totalGames; + + Player? get topScorer { + if (players.isEmpty) return null; + Player best = players.first; + for (final p in players) { + if (p.goalsScored > best.goalsScored) best = p; + } + return best; + } + + Team copyWith({ + String? id, + String? name, + String? logoUrl, + String? description, + int? wins, + int? losses, + int? draws, + List? players, + String? primaryColor, + String? managerId, + String? managerEmail, + String? managerPhone, + String? status, + }) { + return Team( + id: id ?? this.id, + name: name ?? this.name, + logoUrl: logoUrl ?? this.logoUrl, + description: description ?? this.description, + wins: wins ?? this.wins, + losses: losses ?? this.losses, + draws: draws ?? this.draws, + players: players ?? this.players, + primaryColor: primaryColor ?? this.primaryColor, + managerId: managerId ?? this.managerId, + managerEmail: managerEmail ?? this.managerEmail, + managerPhone: managerPhone ?? this.managerPhone, + status: status ?? this.status, + ); + } + + factory Team.fromJson(Map data) { + final rawPlayers = (data['players'] as List?) ?? const []; + return Team( + id: (data['id'] as String?) ?? '', + name: (data['name'] as String?) ?? '', + logoUrl: data['logo_url'] as String?, + description: data['description'] as String?, + wins: (data['wins'] as num?)?.toInt() ?? 0, + losses: (data['losses'] as num?)?.toInt() ?? 0, + draws: (data['draws'] as num?)?.toInt() ?? 0, + players: rawPlayers + .whereType>() + .map(Player.fromMap) + .toList(growable: false), + primaryColor: data['primary_color'] as String?, + managerId: data['manager_id'] as String?, + managerEmail: (data['manager_email'] as String?) ?? '', + managerPhone: data['manager_phone'] as String?, + status: TeamStatus.normalize(data['status'] as String?), + ); + } + + Map toJson() { + return { + 'name': name, + 'logo_url': logoUrl, + 'description': description, + 'wins': wins, + 'losses': losses, + 'draws': draws, + 'primary_color': primaryColor, + 'manager_id': managerId, + 'manager_email': managerEmail, + 'manager_phone': managerPhone, + 'status': status, + 'players': players.map((p) => p.toMap()).toList(growable: false), + }; + } + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + if (other is! Team) return false; + if (other.id != id) return false; + if (other.name != name) return false; + if (other.logoUrl != logoUrl) return false; + if (other.description != description) return false; + if (other.wins != wins) return false; + if (other.losses != losses) return false; + if (other.draws != draws) return false; + if (other.primaryColor != primaryColor) return false; + if (other.managerId != managerId) return false; + if (other.managerEmail != managerEmail) return false; + if (other.managerPhone != managerPhone) return false; + if (other.status != status) return false; + if (other.players.length != players.length) return false; + for (var i = 0; i < players.length; i++) { + if (other.players[i] != players[i]) return false; + } + return true; + } + + @override + int get hashCode => Object.hash( + id, name, logoUrl, description, wins, losses, draws, + primaryColor, managerId, managerEmail, managerPhone, + status, Object.hashAll(players), + ); + + @override + String toString() => + 'Team(id: $id, name: $name, status: $status, record: $record, ' + 'players: ${players.length})'; +} diff --git a/lib/features/teams/infrastructure/teams_repository.dart b/lib/features/teams/infrastructure/teams_repository.dart new file mode 100644 index 0000000..b87e6c9 --- /dev/null +++ b/lib/features/teams/infrastructure/teams_repository.dart @@ -0,0 +1,132 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +import '../../../core/api/api_client.dart'; +import '../domain/join_request.dart'; +import '../domain/team.dart'; + +part 'teams_repository.g.dart'; + +class TeamsRepository { + TeamsRepository(this._api); + + final ApiClient _api; + + Future> fetchTeams({bool adminAll = false}) async { + final params = adminAll ? {'all': '1'} : null; + final data = await _api.get('/teams/index.php', params: params); + final list = (data['teams'] as List?) ?? []; + return list.whereType>().map(Team.fromJson).toList(); + } + + Future getTeam(String id) async { + try { + final data = await _api.get('/teams/detail.php', params: {'id': id}); + return Team.fromJson(data); + } on ApiException catch (e) { + if (e.statusCode == 404) return null; + rethrow; + } + } + + Future createTeam(Team team) async { + final data = await _api.post('/teams/index.php', team.toJson()); + return data['id'] as String; + } + + Future updateTeam(Team team) async { + final data = await _api.put( + '/teams/detail.php', + team.toJson(), + params: {'id': team.id}, + ); + return Team.fromJson(data); + } + + Future updateTeamStatus(String teamId, String status) async { + await _api.put('/teams/detail.php', {'status': status}, params: {'id': teamId}); + } + + Future deleteTeam(String id) async { + await _api.delete('/teams/detail.php', params: {'id': id}); + } + + Future submitJoinRequest({ + required String teamId, + required String teamName, + required String playerId, + required String playerName, + required String playerEmail, + }) async { + final data = await _api.post('/teams/join_requests.php', { + 'team_id': teamId, + 'team_name': teamName, + 'player_name': playerName, + 'player_email': playerEmail, + }); + return data['id'] as String; + } + + Future> fetchJoinRequestsForTeam(String teamId) async { + final data = await _api.get( + '/teams/join_requests.php', + params: {'team_id': teamId}, + ); + final list = (data['requests'] as List?) ?? []; + return list.whereType>().map(JoinRequest.fromJson).toList(); + } + + Future> fetchJoinRequestsForPlayer(String playerId) async { + final data = await _api.get( + '/teams/join_requests.php', + params: {'player_id': playerId}, + ); + final list = (data['requests'] as List?) ?? []; + return list.whereType>().map(JoinRequest.fromJson).toList(); + } + + Future updateJoinRequestStatus(String requestId, String status) async { + await _api.put( + '/teams/join_requests.php', + {'id': requestId, 'status': status}, + params: {'id': requestId}, + ); + } + + Stream> watchTeams() async* { + yield await fetchTeams(); + await for (final _ in Stream.periodic(const Duration(seconds: 30))) { + yield await fetchTeams(); + } + } + + Stream> adminWatchAllTeams() async* { + yield await fetchTeams(adminAll: true); + await for (final _ in Stream.periodic(const Duration(seconds: 30))) { + yield await fetchTeams(adminAll: true); + } + } + + Stream> watchJoinRequestsForTeam(String teamId) async* { + yield await fetchJoinRequestsForTeam(teamId); + await for (final _ in Stream.periodic(const Duration(seconds: 30))) { + yield await fetchJoinRequestsForTeam(teamId); + } + } + + Stream> watchJoinRequestsForPlayer(String playerId) async* { + yield await fetchJoinRequestsForPlayer(playerId); + await for (final _ in Stream.periodic(const Duration(seconds: 30))) { + yield await fetchJoinRequestsForPlayer(playerId); + } + } +} + +@Riverpod(keepAlive: true) +TeamsRepository teamsRepository(TeamsRepositoryRef ref) { + return TeamsRepository(ref.watch(apiClientProvider)); +} + +@riverpod +Stream> teamsStream(TeamsStreamRef ref) { + return ref.watch(teamsRepositoryProvider).watchTeams(); +} diff --git a/lib/features/teams/infrastructure/teams_repository.g.dart b/lib/features/teams/infrastructure/teams_repository.g.dart new file mode 100644 index 0000000..ecd5cf4 --- /dev/null +++ b/lib/features/teams/infrastructure/teams_repository.g.dart @@ -0,0 +1,48 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'teams_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$teamsRepositoryHash() => r'eb7ca229935756d7a761b8dd59a29ffe6238c841'; + +/// See also [teamsRepository]. +@ProviderFor(teamsRepository) +final teamsRepositoryProvider = Provider.internal( + teamsRepository, + name: r'teamsRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$teamsRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef TeamsRepositoryRef = ProviderRef; +String _$teamsStreamHash() => r'1a8b1558c8b4419188620e8a0a11f63260cd382c'; + +/// Stream of teams surfaced to the UI. Currently emits the mock list as a +/// single tick — swap to `ref.watch(teamsRepositoryProvider).watchTeams()` +/// once Firestore is seeded. +/// +/// Copied from [teamsStream]. +@ProviderFor(teamsStream) +final teamsStreamProvider = AutoDisposeStreamProvider>.internal( + teamsStream, + name: r'teamsStreamProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$teamsStreamHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef TeamsStreamRef = AutoDisposeStreamProviderRef>; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/features/teams/presentation/create_team_screen.dart b/lib/features/teams/presentation/create_team_screen.dart new file mode 100644 index 0000000..ba85f1b --- /dev/null +++ b/lib/features/teams/presentation/create_team_screen.dart @@ -0,0 +1,340 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../auth/application/auth_notifier.dart'; +import '../../profile/infrastructure/profile_repository.dart'; +import '../domain/player.dart'; +import '../domain/team.dart'; +import '../infrastructure/teams_repository.dart'; + +/// Public-facing form for any logged-in user to register a new team. +/// +/// Differs from the admin form in two ways: +/// 1. Manager email/phone are first-class fields so the league can contact +/// whoever created the team. +/// 2. Win/loss/draw counters are not exposed — those are reserved for admins. +class CreateTeamScreen extends ConsumerStatefulWidget { + const CreateTeamScreen({super.key}); + + @override + ConsumerState createState() => _CreateTeamScreenState(); +} + +class _CreateTeamScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _nameCtrl = TextEditingController(); + final _logoUrlCtrl = TextEditingController(); + final _descCtrl = TextEditingController(); + final _managerEmailCtrl = TextEditingController(); + final _managerPhoneCtrl = TextEditingController(); + + final List<_PlayerDraft> _roster = <_PlayerDraft>[]; + + bool _hydratedEmail = false; + bool _submitting = false; + + @override + void dispose() { + _nameCtrl.dispose(); + _logoUrlCtrl.dispose(); + _descCtrl.dispose(); + _managerEmailCtrl.dispose(); + _managerPhoneCtrl.dispose(); + for (final p in _roster) { + p.dispose(); + } + super.dispose(); + } + + void _addPlayerRow() { + setState(() => _roster.add(_PlayerDraft())); + } + + void _removePlayerRow(_PlayerDraft draft) { + setState(() { + _roster.remove(draft); + draft.dispose(); + }); + } + + Future _submit() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + + final user = ref.read(authNotifierProvider).valueOrNull; + final id = 'team_${DateTime.now().millisecondsSinceEpoch}'; + + final players = []; + for (var i = 0; i < _roster.length; i++) { + final draft = _roster[i]; + final name = draft.nameCtrl.text.trim(); + if (name.isEmpty) continue; + players.add( + Player( + id: '${id}_p$i', + name: name, + jerseyNumber: int.tryParse(draft.jerseyCtrl.text.trim()), + position: draft.positionCtrl.text.trim().isEmpty + ? null + : draft.positionCtrl.text.trim(), + ), + ); + } + + final team = Team( + id: id, + name: _nameCtrl.text.trim(), + logoUrl: _logoUrlCtrl.text.trim().isEmpty + ? null + : _logoUrlCtrl.text.trim(), + description: _descCtrl.text.trim().isEmpty ? null : _descCtrl.text.trim(), + managerId: user?.uid, + managerEmail: _managerEmailCtrl.text.trim(), + managerPhone: _managerPhoneCtrl.text.trim().isEmpty + ? null + : _managerPhoneCtrl.text.trim(), + players: players, + // Manager-submitted teams require admin approval before going public. + status: TeamStatus.pending, + ); + + setState(() => _submitting = true); + try { + final newId = await ref.read(teamsRepositoryProvider).createTeam(team); + // Stamp the team on the manager's profile so the dashboard finds it. + if (user != null) { + try { + await ref + .read(profileRepositoryProvider) + .updateTeamId(user.uid, newId); + } catch (_) { + // Profile may not exist yet for legacy accounts — non-fatal. + } + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Team submitted — awaiting admin approval.'), + ), + ); + context.go('/manager'); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('Could not create team: $e'))); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override + Widget build(BuildContext context) { + final user = ref.watch(authNotifierProvider).valueOrNull; + if (!_hydratedEmail && user != null) { + _managerEmailCtrl.text = user.email; + _hydratedEmail = true; + } + + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar( + title: const Text('NEW TEAM'), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => context.go('/teams'), + ), + ), + body: SafeArea( + child: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextFormField( + controller: _nameCtrl, + decoration: const InputDecoration(labelText: 'Team name'), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _logoUrlCtrl, + decoration: const InputDecoration( + labelText: 'Logo URL (optional)', + hintText: 'https://...', + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _descCtrl, + decoration: const InputDecoration( + labelText: 'Description (optional)', + ), + minLines: 2, + maxLines: 5, + ), + const SizedBox(height: 24), + Text( + 'CONTACT', + style: theme.textTheme.labelLarge?.copyWith(letterSpacing: 1.5), + ), + const SizedBox(height: 8), + TextFormField( + controller: _managerEmailCtrl, + decoration: const InputDecoration( + labelText: 'Manager email', + prefixIcon: Icon(Icons.mail_outline), + ), + readOnly: true, + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _managerPhoneCtrl, + decoration: const InputDecoration( + labelText: 'Manager phone (optional)', + prefixIcon: Icon(Icons.phone_outlined), + ), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: Text( + 'ROSTER', + style: theme.textTheme.labelLarge?.copyWith( + letterSpacing: 1.5, + ), + ), + ), + OutlinedButton.icon( + onPressed: _addPlayerRow, + icon: const Icon(Icons.add, size: 18), + label: const Text('Add player'), + ), + ], + ), + const SizedBox(height: 8), + if (_roster.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + 'No players yet — tap "Add player" to start your roster.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ) + else + ..._roster.map( + (draft) => _PlayerRow( + draft: draft, + onRemove: () => _removePlayerRow(draft), + ), + ), + const SizedBox(height: 24), + FilledButton.icon( + onPressed: _submitting ? null : _submit, + icon: _submitting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.add_circle_outline), + label: const Text('CREATE TEAM'), + ), + ], + ), + ), + ), + ); + } +} + +class _PlayerDraft { + _PlayerDraft() + : nameCtrl = TextEditingController(), + jerseyCtrl = TextEditingController(), + positionCtrl = TextEditingController(); + + final TextEditingController nameCtrl; + final TextEditingController jerseyCtrl; + final TextEditingController positionCtrl; + + void dispose() { + nameCtrl.dispose(); + jerseyCtrl.dispose(); + positionCtrl.dispose(); + } +} + +class _PlayerRow extends StatelessWidget { + const _PlayerRow({required this.draft, required this.onRemove}); + + final _PlayerDraft draft; + final VoidCallback onRemove; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + margin: const EdgeInsets.symmetric(vertical: 6), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 4, 8), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + controller: draft.nameCtrl, + decoration: const InputDecoration(labelText: 'Player name'), + validator: (v) => + (v == null || v.trim().isEmpty) ? 'Required' : null, + ), + ), + IconButton( + icon: Icon( + Icons.remove_circle_outline, + color: theme.colorScheme.error, + ), + tooltip: 'Remove player', + onPressed: onRemove, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + SizedBox( + width: 88, + child: TextFormField( + controller: draft.jerseyCtrl, + decoration: const InputDecoration(labelText: 'Jersey #'), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: TextFormField( + controller: draft.positionCtrl, + decoration: const InputDecoration(labelText: 'Position'), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/teams/presentation/team_detail_screen.dart b/lib/features/teams/presentation/team_detail_screen.dart new file mode 100644 index 0000000..05036ce --- /dev/null +++ b/lib/features/teams/presentation/team_detail_screen.dart @@ -0,0 +1,507 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../auth/application/auth_notifier.dart'; +import '../../profile/application/profile_notifier.dart'; +import '../../profile/domain/user_profile.dart'; +import '../application/teams_notifier.dart'; +import '../domain/join_request.dart'; +import '../domain/team.dart'; +import '../infrastructure/teams_repository.dart'; +import 'widgets/player_tile.dart'; +import 'widgets/team_record_badge.dart'; + +/// Full-screen view of a single team: header (logo + record), summary stats, +/// and roster. +class TeamDetailScreen extends ConsumerWidget { + const TeamDetailScreen({super.key, required this.teamId}); + + final String teamId; + + /// Web reads better when long content is centered in a column; ~760px is + /// the same max we use across other detail screens. + static const double _maxContentWidth = 760; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final team = ref.watch(teamByIdProvider(teamId)); + + if (team == null) { + return Scaffold( + appBar: AppBar( + title: const Text('Team'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/teams'), + ), + ), + body: const Center(child: Text('Team not found.')), + ); + } + + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/teams'), + ), + title: Text(team.name), + ), + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _maxContentWidth), + child: _TeamDetailBody(team: team), + ), + ), + ); + } +} + +class _TeamDetailBody extends StatelessWidget { + const _TeamDetailBody({required this.team}); + + final Team team; + + @override + Widget build(BuildContext context) { + return CustomScrollView( + slivers: [ + SliverToBoxAdapter(child: _TeamHeader(team: team)), + SliverToBoxAdapter(child: _JoinTeamSection(team: team)), + SliverToBoxAdapter(child: _StatsRow(team: team)), + SliverToBoxAdapter(child: _ContactSection(team: team)), + const SliverToBoxAdapter(child: _SectionDivider(title: 'Roster')), + if (team.players.isEmpty) + const SliverToBoxAdapter(child: _EmptyRoster()) + else + SliverList.separated( + itemCount: team.players.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) => + PlayerTile(player: team.players[index]), + ), + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ); + } +} + +/// Renders one of four states for a logged-in player viewing a team: +/// * already on this team → 'YOUR TEAM' chip +/// * already on another team → no CTA +/// * pending request out → disabled 'Request pending' button +/// * no request yet → primary 'Request to join' OutlinedButton +/// +/// Returns SizedBox.shrink for managers, admins, viewers, or while role is +/// resolving — they have no use for the action. +class _JoinTeamSection extends ConsumerWidget { + const _JoinTeamSection({required this.team}); + + final Team team; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final user = ref.watch(authNotifierProvider).valueOrNull; + final role = ref.watch(currentUserRoleProvider); + final profile = ref.watch(currentProfileProvider).valueOrNull; + + if (user == null || role != UserRole.player || profile == null) { + return const SizedBox.shrink(); + } + + // Player is already on this team. + if (profile.teamId == team.id) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 4), + child: Align( + alignment: Alignment.center, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + 'YOUR TEAM', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Colors.green.shade300, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + ), + ), + ), + ), + ); + } + + // Player is on a different team — no join CTA shown. + if (profile.hasTeam) { + return const SizedBox.shrink(); + } + + final requestsAsync = ref.watch( + joinRequestsForPlayerProvider(user.uid), + ); + + return requestsAsync.when( + loading: () => const SizedBox(height: 0), + error: (_, _) => const SizedBox.shrink(), + data: (requests) { + final hasPendingForThisTeam = requests.any( + (r) => + r.teamId == team.id && r.status == JoinRequestStatus.pending, + ); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 8), + child: hasPendingForThisTeam + ? OutlinedButton.icon( + onPressed: null, + icon: const Icon(Icons.hourglass_bottom, size: 18), + label: const Text('REQUEST PENDING'), + ) + : OutlinedButton.icon( + onPressed: () => _submit(context, ref, profile), + icon: const Icon(Icons.person_add_alt_1, size: 18), + label: const Text('REQUEST TO JOIN'), + ), + ); + }, + ); + } + + Future _submit( + BuildContext context, + WidgetRef ref, + UserProfile profile, + ) async { + final messenger = ScaffoldMessenger.of(context); + try { + await ref.read(teamsRepositoryProvider).submitJoinRequest( + teamId: team.id, + teamName: team.name, + playerId: profile.uid, + playerName: profile.displayName.isEmpty + ? profile.email + : profile.displayName, + playerEmail: profile.email, + ); + messenger.showSnackBar( + const SnackBar(content: Text('Request sent!')), + ); + } catch (e) { + messenger.showSnackBar( + SnackBar(content: Text('Could not send request: $e')), + ); + } + } +} + +class _TeamHeader extends StatelessWidget { + const _TeamHeader({required this.team}); + + final Team team; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final initial = team.name.isEmpty ? '?' : team.name.characters.first; + + final hasLogo = team.logoUrl != null && team.logoUrl!.isNotEmpty; + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 96, + height: 96, + alignment: Alignment.center, + decoration: BoxDecoration( + color: scheme.primaryContainer, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: scheme.primary.withValues(alpha: 0.25), + blurRadius: 24, + spreadRadius: 2, + ), + ], + ), + child: hasLogo + ? CircleAvatar( + radius: 48, + backgroundColor: scheme.primaryContainer, + backgroundImage: NetworkImage(team.logoUrl!), + ) + : Text( + initial.toUpperCase(), + style: TextStyle( + color: scheme.onPrimaryContainer, + fontWeight: FontWeight.w800, + fontSize: 44, + ), + ), + ), + const SizedBox(height: 16), + Text( + team.name, + textAlign: TextAlign.center, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w800, + ), + ), + if (team.description != null && team.description!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + team.description!, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + const SizedBox(height: 16), + TeamRecordBadge( + wins: team.wins, + draws: team.draws, + losses: team.losses, + ), + ], + ), + ); + } +} + +class _StatsRow extends StatelessWidget { + const _StatsRow({required this.team}); + + final Team team; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final winPct = (team.winPercentage * 100).round(); + final topScorer = team.topScorer; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: scheme.outlineVariant), + ), + child: Row( + children: [ + Expanded( + child: _StatColumn(label: 'Games', value: '${team.totalGames}'), + ), + _VerticalDivider(color: scheme.outlineVariant), + Expanded( + child: _StatColumn(label: 'Win %', value: '$winPct%'), + ), + _VerticalDivider(color: scheme.outlineVariant), + Expanded( + child: _StatColumn( + label: 'Top scorer', + value: topScorer == null + ? '—' + : '${topScorer.name.split(' ').first} ' + '(${topScorer.goalsScored})', + small: true, + ), + ), + ], + ), + ), + ); + } +} + +class _ContactSection extends StatelessWidget { + const _ContactSection({required this.team}); + + final Team team; + + @override + Widget build(BuildContext context) { + final hasEmail = team.managerEmail.isNotEmpty; + final hasPhone = team.managerPhone != null && team.managerPhone!.isNotEmpty; + if (!hasEmail && !hasPhone) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: scheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'CONTACT', + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + letterSpacing: 0.8, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + if (hasEmail) + _ContactRow(icon: Icons.mail_outline, label: team.managerEmail), + if (hasEmail && hasPhone) const SizedBox(height: 6), + if (hasPhone) + _ContactRow( + icon: Icons.phone_outlined, + label: team.managerPhone!, + ), + ], + ), + ), + ); + } +} + +class _ContactRow extends StatelessWidget { + const _ContactRow({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Row( + children: [ + Icon(icon, size: 18, color: scheme.primary), + const SizedBox(width: 10), + Expanded( + child: Text( + label, + style: theme.textTheme.bodyMedium?.copyWith( + color: scheme.onSurface, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } +} + +class _StatColumn extends StatelessWidget { + const _StatColumn({ + required this.label, + required this.value, + this.small = false, + }); + + final String label; + final String value; + final bool small; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: + (small + ? theme.textTheme.titleMedium + : theme.textTheme.headlineSmall) + ?.copyWith( + fontWeight: FontWeight.w800, + color: scheme.onSurface, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.onSurfaceVariant, + letterSpacing: 0.4, + ), + ), + ], + ); + } +} + +class _VerticalDivider extends StatelessWidget { + const _VerticalDivider({required this.color}); + + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 36, + margin: const EdgeInsets.symmetric(horizontal: 8), + color: color, + ); + } +} + +class _SectionDivider extends StatelessWidget { + const _SectionDivider({required this.title}); + + final String title; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Row( + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 12), + Expanded(child: Divider(color: theme.colorScheme.outlineVariant)), + ], + ), + ); + } +} + +class _EmptyRoster extends StatelessWidget { + const _EmptyRoster(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Text( + 'No players on the roster yet.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } +} diff --git a/lib/features/teams/presentation/teams_screen.dart b/lib/features/teams/presentation/teams_screen.dart new file mode 100644 index 0000000..090d68a --- /dev/null +++ b/lib/features/teams/presentation/teams_screen.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../domain/team.dart'; +import '../infrastructure/teams_repository.dart'; +import 'widgets/team_card.dart'; + +/// Top-level Teams tab. Renders a responsive grid of team cards on wider +/// viewports and a single-column list on mobile. +class TeamsScreen extends ConsumerWidget { + const TeamsScreen({super.key}); + + /// Width at which the layout switches from list to 2-column grid. + static const double _gridBreakpoint = 640; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final teamsAsync = ref.watch(teamsStreamProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Teams')), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => context.go('/teams/new'), + icon: const Icon(Icons.add), + label: const Text('CREATE A TEAM'), + ), + body: teamsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => _ErrorState( + message: error.toString(), + onRetry: () => ref.invalidate(teamsStreamProvider), + ), + data: (teams) { + if (teams.isEmpty) return const _EmptyState(); + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= _gridBreakpoint; + return isWide + ? _TeamsGrid(teams: teams, maxWidth: constraints.maxWidth) + : _TeamsList(teams: teams); + }, + ); + }, + ), + ); + } +} + +class _TeamsList extends StatelessWidget { + const _TeamsList({required this.teams}); + + final List teams; + + @override + Widget build(BuildContext context) { + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + itemCount: teams.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) => TeamCard(team: teams[index]), + ); + } +} + +class _TeamsGrid extends StatelessWidget { + const _TeamsGrid({required this.teams, required this.maxWidth}); + + final List teams; + final double maxWidth; + + @override + Widget build(BuildContext context) { + // Wider viewports get more columns: 2 up to ~1024, then 3. + final crossAxisCount = maxWidth >= 1024 ? 3 : 2; + // Slightly taller than wide so the top-scorer pill never crowds. + const aspect = 1.55; + + return GridView.builder( + padding: const EdgeInsets.all(16), + itemCount: teams.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: aspect, + ), + itemBuilder: (context, index) => TeamCard(team: teams[index]), + ); + } +} + +class _EmptyState extends StatelessWidget { + const _EmptyState(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.groups_outlined, + size: 64, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(height: 16), + Text('No teams yet', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Teams will appear here once rosters are submitted for an event.', + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +class _ErrorState extends StatelessWidget { + const _ErrorState({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 64, color: theme.colorScheme.error), + const SizedBox(height: 16), + Text('Could not load teams', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 16), + FilledButton.tonalIcon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Try again'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/teams/presentation/widgets/player_tile.dart b/lib/features/teams/presentation/widgets/player_tile.dart new file mode 100644 index 0000000..7209b5c --- /dev/null +++ b/lib/features/teams/presentation/widgets/player_tile.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; + +import '../../domain/player.dart'; + +/// ListTile-style row for one player in a team roster. +class PlayerTile extends StatelessWidget { + const PlayerTile({super.key, required this.player}); + + final Player player; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final initial = player.name.isEmpty ? '?' : player.name.characters.first; + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (player.jerseyNumber != null) ...[ + _JerseyBadge(number: player.jerseyNumber!), + const SizedBox(width: 8), + ], + CircleAvatar( + backgroundColor: scheme.secondaryContainer, + foregroundColor: scheme.onSecondaryContainer, + child: Text( + initial.toUpperCase(), + style: const TextStyle(fontWeight: FontWeight.w700), + ), + ), + ], + ), + title: Text( + player.name, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: player.position == null + ? null + : Text( + player.position!, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _StatPill( + icon: Icons.sports_soccer, + value: player.goalsScored, + color: scheme.primary, + tooltip: 'Goals', + ), + const SizedBox(width: 8), + _StatPill( + icon: Icons.handshake_outlined, + value: player.assists, + color: scheme.tertiary, + tooltip: 'Assists', + ), + ], + ), + ); + } +} + +class _JerseyBadge extends StatelessWidget { + const _JerseyBadge({required this.number}); + + final int number; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + constraints: const BoxConstraints(minWidth: 36), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: scheme.primary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: scheme.primary.withValues(alpha: 0.4)), + ), + child: Text( + '#$number', + textAlign: TextAlign.center, + style: TextStyle( + color: scheme.primary, + fontWeight: FontWeight.w800, + fontSize: 13, + ), + ), + ); + } +} + +class _StatPill extends StatelessWidget { + const _StatPill({ + required this.icon, + required this.value, + required this.color, + required this.tooltip, + }); + + final IconData icon; + final int value; + final Color color; + final String tooltip; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Tooltip( + message: tooltip, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Text( + '$value', + style: TextStyle( + color: scheme.onSurface, + fontWeight: FontWeight.w600, + fontSize: 12.5, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/teams/presentation/widgets/team_card.dart b/lib/features/teams/presentation/widgets/team_card.dart new file mode 100644 index 0000000..d789655 --- /dev/null +++ b/lib/features/teams/presentation/widgets/team_card.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../domain/team.dart'; +import 'team_record_badge.dart'; + +/// Card summarizing one team in the grid/list. Tapping navigates to +/// `/teams/:id`. +class TeamCard extends StatelessWidget { + const TeamCard({super.key, required this.team}); + + final Team team; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + final initial = team.name.isEmpty ? '?' : team.name.characters.first; + final topScorer = team.topScorer; + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => context.go('/teams/${team.id}'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + _TeamInitialAvatar(initial: initial, size: 52), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + team.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.group_outlined, + size: 14, + color: scheme.onSurfaceVariant, + ), + const SizedBox(width: 4), + Text( + '${team.players.length} players', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ), + ], + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + TeamRecordBadge( + wins: team.wins, + draws: team.draws, + losses: team.losses, + dense: true, + ), + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: scheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: topScorer == null + ? Text( + 'No goals scored yet', + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurfaceVariant, + ), + ) + : Row( + children: [ + Icon( + Icons.sports_soccer, + size: 14, + color: scheme.primary, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + 'Top scorer: ${topScorer.name} — ' + '${topScorer.goalsScored} ' + '${topScorer.goalsScored == 1 ? 'goal' : 'goals'}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: scheme.onSurface, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _TeamInitialAvatar extends StatelessWidget { + const _TeamInitialAvatar({required this.initial, required this.size}); + + final String initial; + final double size; + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Container( + width: size, + height: size, + alignment: Alignment.center, + decoration: BoxDecoration( + color: scheme.primaryContainer, + shape: BoxShape.circle, + ), + child: Text( + initial.toUpperCase(), + style: TextStyle( + color: scheme.onPrimaryContainer, + fontWeight: FontWeight.w800, + fontSize: size * 0.46, + ), + ), + ); + } +} diff --git a/lib/features/teams/presentation/widgets/team_record_badge.dart b/lib/features/teams/presentation/widgets/team_record_badge.dart new file mode 100644 index 0000000..7933450 --- /dev/null +++ b/lib/features/teams/presentation/widgets/team_record_badge.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +/// Compact W / D / L pill row used on team cards and the detail header. +/// +/// Win / loss / draw are universally readable colors, so they bypass the +/// color scheme and use semantic green / red / grey tints that remain stable +/// across light and dark themes. +class TeamRecordBadge extends StatelessWidget { + const TeamRecordBadge({ + super.key, + required this.wins, + required this.draws, + required this.losses, + this.dense = false, + }); + + final int wins; + final int draws; + final int losses; + + /// Shrinks padding and font size for tight spaces (e.g. inside a card). + final bool dense; + + @override + Widget build(BuildContext context) { + final spacing = dense ? 6.0 : 8.0; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + _RecordChip( + label: 'W', + value: wins, + color: Colors.green, + dense: dense, + ), + SizedBox(width: spacing), + _RecordChip( + label: 'D', + value: draws, + color: Colors.grey, + dense: dense, + ), + SizedBox(width: spacing), + _RecordChip( + label: 'L', + value: losses, + color: Colors.red, + dense: dense, + ), + ], + ); + } +} + +class _RecordChip extends StatelessWidget { + const _RecordChip({ + required this.label, + required this.value, + required this.color, + required this.dense, + }); + + final String label; + final int value; + final MaterialColor color; + final bool dense; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isDark = theme.brightness == Brightness.dark; + final tint = isDark ? color.shade200 : color.shade800; + final bg = (isDark ? color.shade900 : color.shade100).withValues( + alpha: isDark ? 0.45 : 1.0, + ); + + final hPad = dense ? 8.0 : 10.0; + final vPad = dense ? 4.0 : 6.0; + final fontSize = dense ? 11.0 : 12.5; + + return Container( + padding: EdgeInsets.symmetric(horizontal: hPad, vertical: vPad), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: tint.withValues(alpha: 0.35)), + ), + child: Text( + '$label: $value', + style: TextStyle( + color: tint, + fontWeight: FontWeight.w700, + fontSize: fontSize, + letterSpacing: 0.3, + ), + ), + ); + } +} diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart new file mode 100644 index 0000000..76522b4 --- /dev/null +++ b/lib/firebase_options.dart @@ -0,0 +1,2 @@ +// Firebase has been replaced by a PHP/MySQL backend on Hostinger. +// See server/ for the backend code and lib/core/api/api_client.dart for the client. diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..fad5b1f --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'core/router/app_router.dart'; +import 'core/theme/app_theme.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + runApp(const ProviderScope(child: WindedApp())); +} + +class WindedApp extends ConsumerWidget { + const WindedApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(appRouterProvider); + return MaterialApp.router( + title: 'Winded', + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + routerConfig: router, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..88e8df6 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,938 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f + url: "https://pub.dev" + source: hosted + version: "85.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: f4ad0fea5f102201015c9aae9d93bc02f75dd9491529a8c21f88d17a8523d44c + url: "https://pub.dev" + source: hosted + version: "7.6.0" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: a5ab7590c27b779f3d4de67f31c4109dbe13dd7339f86461a6f2a8ab2594d8ce + url: "https://pub.dev" + source: hosted + version: "0.13.4" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.dev" + source: hosted + version: "2.7.0" + async: + dependency: transitive + description: + name: async + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 + url: "https://pub.dev" + source: hosted + version: "2.13.1" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.dev" + source: hosted + version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" + url: "https://pub.dev" + source: hosted + version: "2.5.4" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" + url: "https://pub.dev" + source: hosted + version: "9.1.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.dev" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "34e4067d30ce212937df995f03b69992eea683539ceeac7f679a1f1eba055b56" + url: "https://pub.dev" + source: hosted + version: "8.12.6" + characters: + dependency: transitive + description: + name: characters + sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.dev" + source: hosted + version: "2.0.4" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" + cli_util: + dependency: transitive + description: + name: cli_util + sha256: ff6785f7e9e3c38ac98b2fb035701789de90154024a75b6cb926445e83197d1c + url: "https://pub.dev" + source: hosted + version: "0.4.2" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + code_builder: + dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.dev" + source: hosted + version: "4.11.1" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + crypto: + dependency: transitive + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" + url: "https://pub.dev" + source: hosted + version: "1.0.9" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "9656925637516c5cf0f5da018b33df94025af2088fe09c8ae2ca54c53f2d9a84" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + custom_lint_builder: + dependency: transitive + description: + name: custom_lint_builder + sha256: "6cdc8e87e51baaaba9c43e283ed8d28e59a0c4732279df62f66f7b5984655414" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "31110af3dde9d29fb10828ca33f1dce24d2798477b167675543ce3d208dee8be" + url: "https://pub.dev" + source: hosted + version: "0.7.5" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "4a86a0d8415a91fbb8298d6ef03e9034dc8e323a599ddc4120a0e36c433983a2" + url: "https://pub.dev" + source: hosted + version: "1.0.0+7.7.0" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + fixnum: + dependency: transitive + description: + name: fixnum + sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be + url: "https://pub.dev" + source: hosted + version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: f2e9137f261d0f53a820f6b829c80ba570ac915284c8e32789d973834796eca0 + url: "https://pub.dev" + source: hosted + version: "0.71.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_riverpod: + dependency: "direct main" + description: + name: flutter_riverpod + sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: + dependency: transitive + description: + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" + url: "https://pub.dev" + source: hosted + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + freezed_annotation: + dependency: transitive + description: + name: freezed_annotation + sha256: "7294967ff0a6d98638e7acb774aac3af2550777accd8149c90af5b014e6d44d8" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: b453934c36e289cef06525734d1e676c1f91da9e22e2017d9dcab6ce0f999175 + url: "https://pub.dev" + source: hosted + version: "15.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + hotreloader: + dependency: transitive + description: + name: hotreloader + sha256: "66871df468fc24eee81f1a0a7cb98acc104716f9b7376d355437b48d633c4ebf" + url: "https://pub.dev" + source: hosted + version: "4.4.0" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" + intl: + dependency: "direct main" + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + json_annotation: + dependency: transitive + description: + name: json_annotation + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 + url: "https://pub.dev" + source: hosted + version: "4.11.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + url: "https://pub.dev" + source: hosted + version: "0.12.18" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + url: "https://pub.dev" + source: hosted + version: "0.13.0" + meta: + dependency: transitive + description: + name: meta + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + url: "https://pub.dev" + source: hosted + version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.dev" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + riverpod: + dependency: transitive + description: + name: riverpod + sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_analyzer_utils: + dependency: transitive + description: + name: riverpod_analyzer_utils + sha256: "03a17170088c63aab6c54c44456f5ab78876a1ddb6032ffde1662ddab4959611" + url: "https://pub.dev" + source: hosted + version: "0.5.10" + riverpod_annotation: + dependency: "direct main" + description: + name: riverpod_annotation + sha256: e14b0bf45b71326654e2705d462f21b958f987087be850afd60578fcd502d1b8 + url: "https://pub.dev" + source: hosted + version: "2.6.1" + riverpod_generator: + dependency: "direct dev" + description: + name: riverpod_generator + sha256: "44a0992d54473eb199ede00e2260bd3c262a86560e3c6f6374503d86d0580e36" + url: "https://pub.dev" + source: hosted + version: "2.6.5" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: "89a52b7334210dbff8605c3edf26cfe69b15062beed5cbfeff2c3812c33c9e35" + url: "https://pub.dev" + source: hosted + version: "2.6.5" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_gen: + dependency: transitive + description: + name: source_gen + sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" + url: "https://pub.dev" + source: hosted + version: "1.10.2" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + state_notifier: + dependency: transitive + description: + name: state_notifier + sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb + url: "https://pub.dev" + source: hosted + version: "1.0.0" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + url: "https://pub.dev" + source: hosted + version: "0.7.9" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + uuid: + dependency: transitive + description: + name: uuid + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" + url: "https://pub.dev" + source: hosted + version: "4.5.3" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + url: "https://pub.dev" + source: hosted + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce + url: "https://pub.dev" + source: hosted + version: "3.1.3" +sdks: + dart: ">=3.11.1 <4.0.0" + flutter: ">=3.38.4" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..21b8d92 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,101 @@ +name: winded +description: "Soccer community tournament and pick-up game management app" +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.11.1 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + + # Navigation + go_router: ^15.1.2 + + # State management + flutter_riverpod: ^2.6.1 + riverpod_annotation: ^2.6.1 + + # HTTP + secure token storage (replaces Firebase) + http: ^1.2.2 + flutter_secure_storage: ^9.2.2 + + # UI / Charts + fl_chart: ^0.71.0 + + # Utilities + intl: ^0.20.2 + +dev_dependencies: + flutter_test: + sdk: flutter + + flutter_lints: ^6.0.0 + build_runner: ^2.4.15 + riverpod_generator: ^2.6.5 + custom_lint: ^0.7.5 + riverpod_lint: ^2.6.5 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - assets/images/ + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/server/api/.htaccess b/server/api/.htaccess new file mode 100644 index 0000000..2b68d7a --- /dev/null +++ b/server/api/.htaccess @@ -0,0 +1,3 @@ +Header always set Access-Control-Allow-Origin "*" +Header always set Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" +Header always set Access-Control-Allow-Headers "Content-Type, Authorization" diff --git a/server/api/auth/login.php b/server/api/auth/login.php new file mode 100644 index 0000000..413fd57 --- /dev/null +++ b/server/api/auth/login.php @@ -0,0 +1,38 @@ +prepare('SELECT * FROM users WHERE email = ?'); +$stmt->execute([$email]); +$row = $stmt->fetch(); + +if (!$row || !password_verify($password, $row['password_hash'])) { + json_err('Invalid email or password', 401); +} + +$role = resolve_role($row['email'], $row['role']); +$token = JWT::encode(['uid' => $row['id'], 'email' => $row['email'], 'role' => $role]); + +json_ok([ + 'token' => $token, + 'user' => [ + 'id' => $row['id'], + 'email' => $row['email'], + 'display_name' => $row['display_name'], + 'role' => $role, + 'bio' => $row['bio'], + 'photo_url' => $row['photo_url'], + 'position' => $row['position'], + 'team_id' => $row['team_id'], + 'created_at' => $row['created_at'], + ], +]); diff --git a/server/api/auth/me.php b/server/api/auth/me.php new file mode 100644 index 0000000..c042e9c --- /dev/null +++ b/server/api/auth/me.php @@ -0,0 +1,43 @@ +prepare('UPDATE users SET ' . implode(', ', $fields) . ' WHERE id = ?') + ->execute($params); +} + +$payload = require_auth(); +$stmt = db()->prepare('SELECT * FROM users WHERE id = ?'); +$stmt->execute([$payload['uid']]); +$row = $stmt->fetch(); +if (!$row) json_err('User not found', 404); + +$role = resolve_role($row['email'], $row['role']); + +json_ok([ + 'id' => $row['id'], + 'email' => $row['email'], + 'display_name' => $row['display_name'], + 'role' => $role, + 'bio' => $row['bio'], + 'photo_url' => $row['photo_url'], + 'position' => $row['position'], + 'team_id' => $row['team_id'], + 'created_at' => $row['created_at'], +]); diff --git a/server/api/auth/register.php b/server/api/auth/register.php new file mode 100644 index 0000000..44f763a --- /dev/null +++ b/server/api/auth/register.php @@ -0,0 +1,44 @@ +prepare('SELECT id FROM users WHERE email = ?'); +$stmt->execute([$email]); +if ($stmt->fetch()) json_err('Email already registered', 409); + +$id = uuid(); +$hash = password_hash($password, PASSWORD_BCRYPT); +$role = resolve_role($email, 'player'); + +$db->prepare( + 'INSERT INTO users (id, email, password_hash, display_name, role) VALUES (?, ?, ?, ?, ?)' +)->execute([$id, $email, $hash, $displayName, $role]); + +$token = JWT::encode(['uid' => $id, 'email' => $email, 'role' => $role]); + +json_ok([ + 'token' => $token, + 'user' => [ + 'id' => $id, + 'email' => $email, + 'display_name' => $displayName, + 'role' => $role, + 'bio' => '', + 'photo_url' => null, + 'position' => null, + 'team_id' => null, + 'created_at' => date('c'), + ], +], 201); diff --git a/server/api/brackets/detail.php b/server/api/brackets/detail.php new file mode 100644 index 0000000..f84d2bd --- /dev/null +++ b/server/api/brackets/detail.php @@ -0,0 +1,50 @@ +prepare('SELECT * FROM brackets WHERE id = ?'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + if (!$row) return null; + $row['rounds'] = $row['rounds_json'] ? json_decode($row['rounds_json'], true) : []; + unset($row['rounds_json']); + return $row; +} + +if ($method === 'GET') { + $b = load_bracket($db, $id); + if (!$b) json_err('Not found', 404); + json_ok($b); +} + +if ($method === 'PUT') { + require_admin(); + $body = body(); + $fields = []; $params = []; + foreach (['name','event_id','status'] as $f) { + if (array_key_exists($f, $body)) { $fields[] = "$f = ?"; $params[] = $body[$f]; } + } + if (array_key_exists('rounds', $body)) { + $fields[] = 'rounds_json = ?'; + $params[] = json_encode($body['rounds']); + } + if (empty($fields)) json_err('Nothing to update'); + $params[] = $id; + $db->prepare('UPDATE brackets SET ' . implode(', ', $fields) . ' WHERE id = ?')->execute($params); + json_ok(load_bracket($db, $id)); +} + +if ($method === 'DELETE') { + require_admin(); + $db->prepare('DELETE FROM brackets WHERE id = ?')->execute([$id]); + json_ok(['deleted' => true]); +} + +json_err('Method not allowed', 405); diff --git a/server/api/brackets/index.php b/server/api/brackets/index.php new file mode 100644 index 0000000..b77db66 --- /dev/null +++ b/server/api/brackets/index.php @@ -0,0 +1,34 @@ +query('SELECT * FROM brackets ORDER BY created_at DESC')->fetchAll(); + $rows = array_map(function ($r) { + $r['rounds'] = $r['rounds_json'] ? json_decode($r['rounds_json'], true) : []; + unset($r['rounds_json']); + return $r; + }, $rows); + json_ok(['brackets' => $rows]); +} + +if ($method === 'POST') { + require_admin(); + $b = body(); + $id = uuid(); + $db->prepare( + 'INSERT INTO brackets (id, name, event_id, status, rounds_json) VALUES (?, ?, ?, ?, ?)' + )->execute([ + $id, + $b['name'] ?? 'New Bracket', + $b['event_id'] ?? null, + $b['status'] ?? 'draft', + json_encode($b['rounds'] ?? []), + ]); + json_ok(['id' => $id], 201); +} + +json_err('Method not allowed', 405); diff --git a/server/api/config/database.php b/server/api/config/database.php new file mode 100644 index 0000000..41c382c --- /dev/null +++ b/server/api/config/database.php @@ -0,0 +1,22 @@ +fnr0E7eS'); +define('DB_CHARSET', 'utf8mb4'); + +function db(): PDO { + static $pdo = null; + if ($pdo === null) { + $dsn = 'mysql:host=' . DB_HOST . ';dbname=' . DB_NAME . ';charset=' . DB_CHARSET; + $pdo = new PDO($dsn, DB_USER, DB_PASS, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + return $pdo; +} diff --git a/server/api/config/helpers.php b/server/api/config/helpers.php new file mode 100644 index 0000000..5df61e3 --- /dev/null +++ b/server/api/config/helpers.php @@ -0,0 +1,66 @@ + $msg]); + exit; +} + +function require_auth(): array { + $h = $_SERVER['HTTP_AUTHORIZATION'] ?? ''; + if (!str_starts_with($h, 'Bearer ')) json_err('Unauthorized', 401); + $payload = JWT::decode(substr($h, 7)); + if ($payload === null) json_err('Unauthorized', 401); + return $payload; +} + +function require_admin(): array { + $p = require_auth(); + if (($p['role'] ?? '') !== 'admin') json_err('Forbidden', 403); + return $p; +} + +function require_manager_or_admin(): array { + $p = require_auth(); + $r = $p['role'] ?? ''; + if ($r !== 'admin' && $r !== 'manager') json_err('Forbidden', 403); + return $p; +} + +function uuid(): string { + $b = random_bytes(16); + $b[6] = chr(ord($b[6]) & 0x0f | 0x40); + $b[8] = chr(ord($b[8]) & 0x3f | 0x80); + return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($b), 4)); +} + +function body(): array { + return json_decode(file_get_contents('php://input'), true) ?? []; +} + +function resolve_role(string $email, string $dbRole): string { + return in_array(strtolower(trim($email)), ADMIN_EMAILS) ? 'admin' : $dbRole; +} diff --git a/server/api/config/jwt.php b/server/api/config/jwt.php new file mode 100644 index 0000000..ec935e7 --- /dev/null +++ b/server/api/config/jwt.php @@ -0,0 +1,34 @@ + 'HS256', 'typ' => 'JWT'])); + $payload['iat'] = time(); + $payload['exp'] = time() + JWT_TTL; + $payload = self::b64e(json_encode($payload)); + $sig = self::b64e(hash_hmac('sha256', "$header.$payload", JWT_SECRET, true)); + return "$header.$payload.$sig"; + } + + public static function decode(string $token): ?array { + $parts = explode('.', $token); + if (count($parts) !== 3) return null; + [$header, $payload, $sig] = $parts; + $expected = self::b64e(hash_hmac('sha256', "$header.$payload", JWT_SECRET, true)); + if (!hash_equals($expected, $sig)) return null; + $data = json_decode(self::b64d($payload), true); + if (!$data || ($data['exp'] ?? 0) < time()) return null; + return $data; + } + + private static function b64e(string $v): string { + return rtrim(strtr(base64_encode($v), '+/', '-_'), '='); + } + + private static function b64d(string $v): string { + return base64_decode(strtr($v, '-_', '+/') . str_repeat('=', (4 - strlen($v) % 4) % 4)); + } +} diff --git a/server/api/events/detail.php b/server/api/events/detail.php new file mode 100644 index 0000000..8d0da99 --- /dev/null +++ b/server/api/events/detail.php @@ -0,0 +1,44 @@ +prepare('SELECT * FROM events WHERE id = ?'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + if (!$row) json_err('Not found', 404); + $s = $db->prepare('SELECT COUNT(*) as cnt FROM event_registrations WHERE event_id = ?'); + $s->execute([$id]); + $row['teams_registered'] = (int)$s->fetch()['cnt']; + json_ok($row); +} + +if ($method === 'PUT') { + require_admin(); + $b = body(); + $fields = []; $params = []; + foreach (['title','description','category','event_date','location', + 'registration_deadline','max_teams','is_cancelled','image_url'] as $f) { + if (array_key_exists($f, $b)) { $fields[] = "$f = ?"; $params[] = $b[$f]; } + } + if (empty($fields)) json_err('Nothing to update'); + $params[] = $id; + $db->prepare('UPDATE events SET ' . implode(', ', $fields) . ' WHERE id = ?')->execute($params); + json_ok(['updated' => true]); +} + +if ($method === 'DELETE') { + require_admin(); + $db->prepare('DELETE FROM events WHERE id = ?')->execute([$id]); + $db->prepare('DELETE FROM event_registrations WHERE event_id = ?')->execute([$id]); + json_ok(['deleted' => true]); +} + +json_err('Method not allowed', 405); diff --git a/server/api/events/index.php b/server/api/events/index.php new file mode 100644 index 0000000..a247d7a --- /dev/null +++ b/server/api/events/index.php @@ -0,0 +1,43 @@ +query('SELECT * FROM events ORDER BY event_date ASC')->fetchAll(); + // Attach registration count per event + $db = db(); + $result = array_map(function ($row) use ($db) { + $stmt = $db->prepare('SELECT COUNT(*) as cnt FROM event_registrations WHERE event_id = ?'); + $stmt->execute([$row['id']]); + $row['teams_registered'] = (int)$stmt->fetch()['cnt']; + return $row; + }, $rows); + json_ok(['events' => $result]); +} + +if ($method === 'POST') { + require_admin(); + $b = body(); + $id = uuid(); + db()->prepare( + 'INSERT INTO events (id, title, description, category, event_date, location, + registration_deadline, max_teams, is_cancelled, image_url) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)' + )->execute([ + $id, + $b['title'] ?? '', + $b['description'] ?? '', + $b['category'] ?? 'pickup', + $b['event_date'] ?? date('Y-m-d H:i:s'), + $b['location'] ?? '', + $b['registration_deadline'] ?? null, + (int)($b['max_teams'] ?? 0), + (int)($b['is_cancelled'] ?? 0), + $b['image_url'] ?? null, + ]); + json_ok(['id' => $id], 201); +} + +json_err('Method not allowed', 405); diff --git a/server/api/events/register.php b/server/api/events/register.php new file mode 100644 index 0000000..8a2dfcb --- /dev/null +++ b/server/api/events/register.php @@ -0,0 +1,40 @@ +prepare( + 'INSERT INTO event_registrations (id, event_id, user_id) VALUES (?, ?, ?)' + )->execute([uuid(), $event_id, $uid]); + } catch (PDOException $e) { + // Unique constraint: already registered — treat as success + } + json_ok(['registered' => true]); +} + +if ($method === 'DELETE') { + $db->prepare( + 'DELETE FROM event_registrations WHERE event_id = ? AND user_id = ?' + )->execute([$event_id, $uid]); + json_ok(['unregistered' => true]); +} + +if ($method === 'GET') { + $stmt = $db->prepare( + 'SELECT * FROM event_registrations WHERE event_id = ? AND user_id = ?' + ); + $stmt->execute([$event_id, $uid]); + json_ok(['registered' => (bool)$stmt->fetch()]); +} + +json_err('Method not allowed', 405); diff --git a/server/api/media/index.php b/server/api/media/index.php new file mode 100644 index 0000000..fc32018 --- /dev/null +++ b/server/api/media/index.php @@ -0,0 +1,47 @@ +query('SELECT * FROM media_links ORDER BY sort_order')->fetchAll(); + $highlights = $db->query('SELECT * FROM highlights ORDER BY sort_order DESC')->fetchAll(); + json_ok(['links' => $links, 'highlights' => $highlights]); +} + +if ($method === 'POST') { + require_admin(); + $b = body(); + $type = $b['type'] ?? ''; + + if ($type === 'link') { + $id = uuid(); + $db->prepare( + 'INSERT INTO media_links (id, platform, handle, url, display_name, sort_order) + VALUES (?, ?, ?, ?, ?, ?)' + )->execute([ + $id, $b['platform'] ?? '', $b['handle'] ?? '', + $b['url'] ?? '', $b['display_name'] ?? '', (int)($b['sort_order'] ?? 0), + ]); + json_ok(['id' => $id], 201); + } + + if ($type === 'highlight') { + $id = uuid(); + $db->prepare( + 'INSERT INTO highlights (id, title, description, youtube_url, thumbnail_url, published_at, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?)' + )->execute([ + $id, $b['title'] ?? '', $b['description'] ?? '', + $b['youtube_url'] ?? '', $b['thumbnail_url'] ?? null, + $b['published_at'] ?? null, (int)($b['sort_order'] ?? 0), + ]); + json_ok(['id' => $id], 201); + } + + json_err('type must be link or highlight'); +} + +json_err('Method not allowed', 405); diff --git a/server/api/profiles/detail.php b/server/api/profiles/detail.php new file mode 100644 index 0000000..0882a50 --- /dev/null +++ b/server/api/profiles/detail.php @@ -0,0 +1,43 @@ +prepare('SELECT * FROM users WHERE id = ?'); + $stmt->execute([$uid]); + $row = $stmt->fetch(); + if (!$row) json_err('Not found', 404); + unset($row['password_hash']); + $row['role'] = resolve_role($row['email'], $row['role']); + json_ok($row); +} + +if ($method === 'PUT') { + $payload = require_auth(); + // Users can only update themselves; admins can update anyone. + if ($payload['role'] !== 'admin' && $payload['uid'] !== $uid) json_err('Forbidden', 403); + + $b = body(); + $fields = []; $params = []; + foreach (['display_name','bio','photo_url','position','team_id','role'] as $f) { + if (array_key_exists($f, $b)) { $fields[] = "$f = ?"; $params[] = $b[$f]; } + } + if (empty($fields)) json_err('Nothing to update'); + $params[] = $uid; + $db->prepare('UPDATE users SET ' . implode(', ', $fields) . ' WHERE id = ?')->execute($params); + + $stmt = $db->prepare('SELECT * FROM users WHERE id = ?'); + $stmt->execute([$uid]); + $row = $stmt->fetch(); + unset($row['password_hash']); + $row['role'] = resolve_role($row['email'], $row['role']); + json_ok($row); +} + +json_err('Method not allowed', 405); diff --git a/server/api/stats/index.php b/server/api/stats/index.php new file mode 100644 index 0000000..ac77f75 --- /dev/null +++ b/server/api/stats/index.php @@ -0,0 +1,31 @@ +query( + "SELECT p.id, p.name, p.position, p.goals_scored, p.assists, p.team_id, + t.name AS team_name + FROM players p + JOIN teams t ON t.id = p.team_id + WHERE t.status = 'approved' + ORDER BY p.goals_scored DESC, p.assists DESC + LIMIT 50" +)->fetchAll(); + +// Team leaderboard +$teams = $db->query( + "SELECT id, name, wins, draws, losses, + (wins + draws + losses) AS total_games, + CASE WHEN (wins+draws+losses)=0 THEN 0 + ELSE ROUND(wins/(wins+draws+losses)*100,1) END AS win_pct + FROM teams WHERE status='approved' + ORDER BY wins DESC, win_pct DESC + LIMIT 30" +)->fetchAll(); + +json_ok(['players' => $players, 'teams' => $teams]); diff --git a/server/api/suggestions/detail.php b/server/api/suggestions/detail.php new file mode 100644 index 0000000..f5f5d59 --- /dev/null +++ b/server/api/suggestions/detail.php @@ -0,0 +1,26 @@ +prepare('UPDATE suggestions SET status = ? WHERE id = ?')->execute([$status, $id]); + json_ok(['updated' => true]); +} + +if ($method === 'DELETE') { + require_admin(); + $db->prepare('DELETE FROM suggestions WHERE id = ?')->execute([$id]); + json_ok(['deleted' => true]); +} + +json_err('Method not allowed', 405); diff --git a/server/api/suggestions/index.php b/server/api/suggestions/index.php new file mode 100644 index 0000000..c41b5c0 --- /dev/null +++ b/server/api/suggestions/index.php @@ -0,0 +1,45 @@ +query('SELECT * FROM suggestions ORDER BY submitted_at DESC')->fetchAll(); + } else { + $stmt = $db->prepare( + "SELECT * FROM suggestions WHERE user_id = ? AND is_anonymous = 0 + ORDER BY submitted_at DESC" + ); + $stmt->execute([$payload['uid']]); + $rows = $stmt->fetchAll(); + } + json_ok(['suggestions' => $rows]); +} + +if ($method === 'POST') { + $payload = require_auth(); + $b = body(); + $text = trim($b['text'] ?? ''); + $anon = !empty($b['is_anonymous']); + + if ($text === '') json_err('Text required'); + + $id = uuid(); + $db->prepare( + 'INSERT INTO suggestions (id, user_id, display_name, text, is_anonymous) + VALUES (?, ?, ?, ?, ?)' + )->execute([ + $id, + $anon ? null : $payload['uid'], + $anon ? null : ($b['display_name'] ?? ''), + $text, + $anon ? 1 : 0, + ]); + json_ok(['id' => $id], 201); +} + +json_err('Method not allowed', 405); diff --git a/server/api/teams/detail.php b/server/api/teams/detail.php new file mode 100644 index 0000000..fafeebe --- /dev/null +++ b/server/api/teams/detail.php @@ -0,0 +1,84 @@ +prepare('SELECT * FROM teams WHERE id = ?'); + $stmt->execute([$id]); + $row = $stmt->fetch(); + if (!$row) return null; + $ps = $db->prepare('SELECT * FROM players WHERE team_id = ? ORDER BY name'); + $ps->execute([$id]); + $row['players'] = $ps->fetchAll(); + return $row; +} + +if ($method === 'GET') { + $team = load_team($db, $id); + if (!$team) json_err('Not found', 404); + json_ok($team); +} + +if ($method === 'PUT') { + $payload = require_auth(); + $b = body(); + + // Allow admin or the team's own manager + $stmt = $db->prepare('SELECT manager_id FROM teams WHERE id = ?'); + $stmt->execute([$id]); + $t = $stmt->fetch(); + if (!$t) json_err('Not found', 404); + if ($payload['role'] !== 'admin' && $payload['uid'] !== $t['manager_id']) { + json_err('Forbidden', 403); + } + + // Update scalar fields + $allowed = ['name','description','logo_url','primary_color','manager_email', + 'manager_phone','wins','draws','losses','status']; + $fields = []; $params = []; + foreach ($allowed as $f) { + if (array_key_exists($f, $b)) { $fields[] = "$f = ?"; $params[] = $b[$f]; } + } + if (!empty($fields)) { + $params[] = $id; + $db->prepare('UPDATE teams SET ' . implode(', ', $fields) . ' WHERE id = ?')->execute($params); + } + + // Sync players if provided + if (isset($b['players']) && is_array($b['players'])) { + $db->prepare('DELETE FROM players WHERE team_id = ?')->execute([$id]); + foreach ($b['players'] as $p) { + $pid = $p['id'] ?? uuid(); + $db->prepare( + 'INSERT INTO players (id, team_id, user_id, name, number, position, goals_scored, assists) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)' + )->execute([ + $pid, $id, + $p['user_id'] ?? null, + $p['name'] ?? '', + $p['number'] ?? null, + $p['position'] ?? null, + (int)($p['goals_scored'] ?? 0), + (int)($p['assists'] ?? 0), + ]); + } + } + + json_ok(load_team($db, $id)); +} + +if ($method === 'DELETE') { + require_admin(); + $db->prepare('DELETE FROM players WHERE team_id = ?')->execute([$id]); + $db->prepare('DELETE FROM join_requests WHERE team_id = ?')->execute([$id]); + $db->prepare('DELETE FROM teams WHERE id = ?')->execute([$id]); + json_ok(['deleted' => true]); +} + +json_err('Method not allowed', 405); diff --git a/server/api/teams/index.php b/server/api/teams/index.php new file mode 100644 index 0000000..5333eff --- /dev/null +++ b/server/api/teams/index.php @@ -0,0 +1,56 @@ +prepare('SELECT * FROM players WHERE team_id = ? ORDER BY name'); + $stmt->execute([$row['id']]); + $row['players'] = $stmt->fetchAll(); + return $row; +} + +if ($method === 'GET') { + $admin = isset($_GET['all']); + if ($admin) require_admin(); + $sql = $admin + ? 'SELECT * FROM teams ORDER BY name' + : "SELECT * FROM teams WHERE status = 'approved' ORDER BY name"; + $rows = $db->query($sql)->fetchAll(); + $rows = array_map(fn($r) => team_with_players($db, $r), $rows); + json_ok(['teams' => $rows]); +} + +if ($method === 'POST') { + $payload = require_auth(); + $b = body(); + $id = uuid(); + $role = $payload['role']; + $status = ($role === 'admin') ? 'approved' : 'pending'; + + $db->prepare( + 'INSERT INTO teams (id, name, description, logo_url, primary_color, status, + manager_id, manager_email, manager_phone) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)' + )->execute([ + $id, + $b['name'] ?? '', + $b['description'] ?? null, + $b['logo_url'] ?? null, + $b['primary_color'] ?? null, + $status, + $payload['uid'], + $b['manager_email'] ?? $payload['email'], + $b['manager_phone'] ?? null, + ]); + + // Stamp team on manager profile + $db->prepare('UPDATE users SET team_id = ?, role = ? WHERE id = ?') + ->execute([$id, 'manager', $payload['uid']]); + + json_ok(['id' => $id, 'status' => $status], 201); +} + +json_err('Method not allowed', 405); diff --git a/server/api/teams/join_requests.php b/server/api/teams/join_requests.php new file mode 100644 index 0000000..c1ee833 --- /dev/null +++ b/server/api/teams/join_requests.php @@ -0,0 +1,88 @@ +prepare( + 'SELECT * FROM join_requests WHERE team_id = ? ORDER BY requested_at DESC' + ); + $stmt->execute([$_GET['team_id']]); + } elseif (isset($_GET['player_id'])) { + $stmt = $db->prepare( + 'SELECT * FROM join_requests WHERE player_id = ? ORDER BY requested_at DESC' + ); + $stmt->execute([$_GET['player_id']]); + } else { + json_err('Provide team_id or player_id'); + } + json_ok(['requests' => $stmt->fetchAll()]); +} + +if ($method === 'POST') { + $payload = require_auth(); + $b = body(); + $team_id = $b['team_id'] ?? ''; + $player_id = $payload['uid']; + $player_name = $b['player_name'] ?? ''; + $player_email= $b['player_email']?? $payload['email']; + $team_name = $b['team_name'] ?? ''; + + if ($team_id === '') json_err('team_id required'); + + // Idempotent: return existing pending request if one exists + $stmt = $db->prepare( + "SELECT id FROM join_requests WHERE team_id=? AND player_id=? AND status='pending'" + ); + $stmt->execute([$team_id, $player_id]); + $existing = $stmt->fetch(); + if ($existing) json_ok(['id' => $existing['id']]); + + $id = uuid(); + $db->prepare( + 'INSERT INTO join_requests (id, team_id, team_name, player_id, player_name, player_email) + VALUES (?, ?, ?, ?, ?, ?)' + )->execute([$id, $team_id, $team_name, $player_id, $player_name, $player_email]); + json_ok(['id' => $id], 201); +} + +if ($method === 'PUT') { + $payload = require_auth(); + $b = body(); + $request_id= $_GET['id'] ?? ($b['id'] ?? ''); + $status = $b['status'] ?? ''; + + if ($request_id === '' || $status === '') json_err('id and status required'); + if (!in_array($status, ['approved','rejected'])) json_err('Invalid status'); + + $db->prepare('UPDATE join_requests SET status = ? WHERE id = ?') + ->execute([$status, $request_id]); + + if ($status === 'approved') { + // Stamp team_id on the player's profile + $stmt = $db->prepare('SELECT * FROM join_requests WHERE id = ?'); + $stmt->execute([$request_id]); + $req = $stmt->fetch(); + if ($req) { + $db->prepare('UPDATE users SET team_id = ? WHERE id = ?') + ->execute([$req['team_id'], $req['player_id']]); + // Add player to players table + $exists = $db->prepare('SELECT id FROM players WHERE team_id=? AND user_id=?'); + $exists->execute([$req['team_id'], $req['player_id']]); + if (!$exists->fetch()) { + $db->prepare( + 'INSERT INTO players (id, team_id, user_id, name) VALUES (?, ?, ?, ?)' + )->execute([uuid(), $req['team_id'], $req['player_id'], $req['player_name']]); + } + } + } + + json_ok(['updated' => true]); +} + +json_err('Method not allowed', 405); diff --git a/server/api/upload/index.php b/server/api/upload/index.php new file mode 100644 index 0000000..90b85fa --- /dev/null +++ b/server/api/upload/index.php @@ -0,0 +1,34 @@ + $maxBytes) json_err('File exceeds 5 MB limit'); + +$ext = pathinfo($file['name'], PATHINFO_EXTENSION); +$filename = uuid() . '.' . strtolower($ext); +$uploadDir= __DIR__ . '/../../uploads/' . $context . '/'; + +if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true); + +$dest = $uploadDir . $filename; +if (!move_uploaded_file($file['tmp_name'], $dest)) json_err('Upload failed', 500); + +// Build public URL — adjust the base URL to match your Hostinger domain. +$baseUrl = (isset($_SERVER['HTTPS']) ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST']; +$url = $baseUrl . '/uploads/' . $context . '/' . $filename; + +json_ok(['url' => $url]); diff --git a/server/schema.sql b/server/schema.sql new file mode 100644 index 0000000..33464fc --- /dev/null +++ b/server/schema.sql @@ -0,0 +1,121 @@ +-- Winded – MySQL schema +-- Run this once in your Hostinger MySQL database panel (phpMyAdmin or CLI). + +CREATE TABLE IF NOT EXISTS users ( + id VARCHAR(36) PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(255) DEFAULT '', + role ENUM('player','manager','admin') DEFAULT 'player', + bio TEXT DEFAULT '', + photo_url VARCHAR(500) DEFAULT NULL, + position VARCHAR(50) DEFAULT NULL, + team_id VARCHAR(36) DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS events ( + id VARCHAR(36) PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + category ENUM('tournament','pickup') DEFAULT 'pickup', + event_date DATETIME NOT NULL, + location VARCHAR(500) DEFAULT '', + registration_deadline DATETIME DEFAULT NULL, + max_teams INT DEFAULT 0, + is_cancelled TINYINT(1) DEFAULT 0, + image_url VARCHAR(500) DEFAULT NULL, + created_by VARCHAR(36) DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS event_registrations ( + id VARCHAR(36) PRIMARY KEY, + event_id VARCHAR(36) NOT NULL, + user_id VARCHAR(36) NOT NULL, + registered_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY unique_reg (event_id, user_id) +); + +CREATE TABLE IF NOT EXISTS teams ( + id VARCHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + description TEXT DEFAULT NULL, + logo_url VARCHAR(500) DEFAULT NULL, + primary_color VARCHAR(20) DEFAULT NULL, + status ENUM('pending','approved','rejected') DEFAULT 'pending', + manager_id VARCHAR(36) DEFAULT NULL, + manager_email VARCHAR(255) DEFAULT '', + manager_phone VARCHAR(50) DEFAULT NULL, + wins INT DEFAULT 0, + draws INT DEFAULT 0, + losses INT DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS players ( + id VARCHAR(36) PRIMARY KEY, + team_id VARCHAR(36) NOT NULL, + user_id VARCHAR(36) DEFAULT NULL, + name VARCHAR(255) NOT NULL, + number INT DEFAULT NULL, + position VARCHAR(50) DEFAULT NULL, + goals_scored INT DEFAULT 0, + assists INT DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS join_requests ( + id VARCHAR(36) PRIMARY KEY, + team_id VARCHAR(36) NOT NULL, + team_name VARCHAR(255) DEFAULT '', + player_id VARCHAR(36) NOT NULL, + player_name VARCHAR(255) DEFAULT '', + player_email VARCHAR(255) DEFAULT '', + status ENUM('pending','approved','rejected') DEFAULT 'pending', + requested_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- Brackets store rounds+matches as a JSON blob for MVP simplicity. +CREATE TABLE IF NOT EXISTS brackets ( + id VARCHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + event_id VARCHAR(36) DEFAULT NULL, + status ENUM('draft','active','completed') DEFAULT 'draft', + rounds_json LONGTEXT DEFAULT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS suggestions ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) DEFAULT NULL, + display_name VARCHAR(255) DEFAULT NULL, + text TEXT NOT NULL, + is_anonymous TINYINT(1) DEFAULT 0, + status ENUM('pending','reviewed','implemented') DEFAULT 'pending', + submitted_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS media_links ( + id VARCHAR(36) PRIMARY KEY, + platform VARCHAR(100) NOT NULL, + handle VARCHAR(255) DEFAULT '', + url VARCHAR(500) NOT NULL, + display_name VARCHAR(255) DEFAULT '', + sort_order INT DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS highlights ( + id VARCHAR(36) PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + youtube_url VARCHAR(500) NOT NULL, + thumbnail_url VARCHAR(500) DEFAULT NULL, + published_at DATE DEFAULT NULL, + sort_order INT DEFAULT 0 +); diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..9a4c35d --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility in the flutter_test package. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:winded/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(const MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..de25a65 --- /dev/null +++ b/web/index.html @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + winded + + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..14df182 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "winded", + "short_name": "winded", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "Soccer community tournament and pick-up game management app", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +}