diff --git a/.agents/skills/mobile-app-ui-design/.gitignore b/.agents/skills/mobile-app-ui-design/.gitignore new file mode 100644 index 0000000..33bc2f1 --- /dev/null +++ b/.agents/skills/mobile-app-ui-design/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +*.swp +*.swo +*~ +node_modules/ +.env diff --git a/.agents/skills/mobile-app-ui-design/INDEX.md b/.agents/skills/mobile-app-ui-design/INDEX.md new file mode 100644 index 0000000..0527767 --- /dev/null +++ b/.agents/skills/mobile-app-ui-design/INDEX.md @@ -0,0 +1,131 @@ +# Codebase Index + +## Overview +This repository contains a comprehensive mobile app UI/UX design skill and reference guide focused on creating professional, polished mobile interfaces. + +## File Structure + +``` +mobile-app-ui-design/ +├── SKILL.md # Mobile App UI/UX Design Skill Guide +├── references/ +│ └── industry-conventions.md # Industry-Specific Design Reference +├── INDEX.md # This file +└── README.md # Documentation & Installation +``` + +--- + +## SKILL.md +**Mobile App UI/UX Design Skill** + +A comprehensive guide for designing high-quality mobile app interfaces following proven design principles from top-tier apps. + +### Key Sections: +- **Core Philosophy** (lines 10-18): User-centric design approach +- **Design Process** (lines 20-96): 5-step framework + - Step 1: Understand Context (lines 23-27) + - Step 2: Structure First - UX Lens (lines 29-37) + - Step 3: Visual Design - UI Lens (lines 39-76) + - Typography (lines 41-46) + - Color System - 60/30/10 Rule (lines 48-56) + - Spacing - 8-Point Grid (lines 58-64) + - Shadows (lines 66-69) + - Visual Cues & Imagery (lines 71-77) + - Step 4: Design for Emotion - Peak-End Rule (lines 79-87) + - Step 5: Polish & Details (lines 89-96) +- **Smart Patterns** (lines 98-124): User-stage personalization, search, status tracking, categories +- **Anti-Patterns to Avoid** (lines 126-136): Common design mistakes +- **Implementation Notes** (lines 138-149): Technical guidance for React/Tailwind + +### Design Principles Summary: +- One font family, max 4 sizes, 2 weights +- 60/30/10 color rule +- 8-point grid spacing system +- Soft shadows matched to backgrounds +- Thumb-zone primary actions +- Emotional feedback loops +- Peak-end rule application + +--- + +## references/industry-conventions.md +**Industry Conventions & Emotional Design Reference** + +Deep dive into industry-specific design languages and psychological principles for emotional design. + +### Key Sections: +- **Industry-Specific Design Languages** (lines 3-55) + - AI/Tech (lines 7-11) + - Crypto/Web3 (lines 13-17): Phantom case study + - Finance/Banking (lines 19-23): Revolut case study + - Health/Wellness (lines 25-29): Ahead case study + - Sleep/Meditation (lines 31-34) + - Education/Learning (lines 36-39): Duolingo case study + - Fitness (lines 41-44) + - Productivity (lines 46-49) + - E-commerce/Food (lines 51-54) + +- **Emotional Design Principles** (lines 59-120) + - Peak-End Rule (lines 60-96): Nobel Prize research by Daniel Kahneman + - Emotional Feedback Loops (lines 98-103) + - Three Strategic Principles from Spotify (lines 105-120) + - The Trojan Horse: Hide complexity + - The Vanity Mirror: Identity-based sharing + - The Comfort Trap: Consistency as moat + +- **Design Process for Client/Product Work** (lines 124-152) + - Phase 1: Discovery & Inspiration (lines 126-129) + - Phase 2: UI Direction (lines 131-135) + - Phase 3: UX Structure (lines 137-144) + - Phase 4: Combine UI + UX (lines 146-148) + - UI vs UX lens separation (lines 150-152) + +### Key Insights: +- Industry conventions build familiarity vs. breaking them stands out +- Peak-end rule: users remember the most intense moment + final impression +- Emotional feedback > functional feedback +- Separate UI and UX research lenses + +--- + +## Quick Reference Guide + +### When to Use This Skill +- Designing mobile app screens +- Creating app mockups +- Building mobile UI components +- Improving existing mobile designs +- Creating onboarding flows +- Designing mobile navigation +- Building React Native/Flutter/SwiftUI prototypes + +### Core Design Framework +1. **Understand** → Context, user, primary action, industry conventions +2. **Structure** → User flow, MVP elements, thumb zone, F-pattern +3. **Visual Design** → Typography, color (60/30/10), spacing (8pt grid), shadows +4. **Emotion** → Peak moment, ending, feedback loops +5. **Polish** → Micro-interactions, states, accessibility + +### Design Laws Applied +- **60/30/10 Color Rule**: 60% neutral, 30% complementary, 10% accent +- **8-Point Grid**: All spacing divisible by 8 or 4 +- **Peak-End Rule**: Users remember peak + ending moments +- **Thumb Zone**: Primary actions in bottom 1/3 of screen +- **F-Pattern**: Natural reading/scanning order +- **Typography Hierarchy**: Max 4 sizes, 2 weights, opacity for hierarchy + +--- + +## Implementation Tech Stack +- React (for artifacts) +- Tailwind CSS (utility classes) +- Lucide React (iconography) +- Recharts (data visualization) +- CSS transitions (micro-interactions) +- Mobile-first design (375px baseline) + +--- + +## Tags +`mobile-ui` `ux-design` `emotional-design` `design-system` `react-native` `flutter` `swiftui` `tailwind` `design-principles` `app-design` diff --git a/.agents/skills/mobile-app-ui-design/README.md b/.agents/skills/mobile-app-ui-design/README.md new file mode 100644 index 0000000..a8873fe --- /dev/null +++ b/.agents/skills/mobile-app-ui-design/README.md @@ -0,0 +1,135 @@ +# Mobile App UI/UX Design Skill + +[![Install with npx](https://img.shields.io/badge/install-npx%20skills%20add-blue)](https://skills.sh) +[![Agent Skills](https://img.shields.io/badge/agent-skills-purple)](https://agentskills.io) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) + +A comprehensive Claude Code skill for designing professional, polished mobile app interfaces that follow proven design principles from top-tier apps like Airbnb, Duolingo, Spotify, Revolut, and Phantom. + +## Overview + +This skill guides the creation of mobile app UIs that are intentional, smooth, personal, and alive — not just functional. It covers everything from design philosophy to implementation details. + +## Features + +- **Complete Design Process**: 5-step framework from context to polish +- **Industry-Specific Conventions**: Design patterns for 9+ industries (AI, Crypto, Finance, Health, etc.) +- **Emotional Design Principles**: Peak-End Rule, feedback loops, strategic patterns +- **Design Systems**: Typography, color (60/30/10 rule), spacing (8-point grid) +- **Implementation Guidance**: React, Tailwind CSS, component patterns + +## When to Use + +This skill automatically triggers when you ask Claude to: +- Design a mobile app screen +- Create app mockups +- Build mobile UI components +- Improve an existing mobile app design +- Create onboarding flows +- Design mobile navigation +- Build React Native/Flutter/SwiftUI interfaces + +## Design Principles + +### Core Philosophy +Great mobile UI isn't about flashiness — it's about intentionality. Every pixel, spacing value, and color choice should serve the user. + +### Key Design Laws +- **60/30/10 Color Rule**: 60% neutral, 30% complementary, 10% accent +- **8-Point Grid System**: All spacing divisible by 8 or 4 +- **Peak-End Rule**: Users remember peak moment + ending +- **Thumb Zone**: Primary actions in bottom 1/3 of screen +- **F-Pattern**: Natural reading/scanning order +- **Typography Hierarchy**: Max 4 sizes, 2 weights + +## Examples + +``` +"Design a fitness app home screen" +"Create a banking app dashboard" +"Build a meditation app interface" +"Design a crypto wallet transaction screen" +"Create an onboarding flow for a health app" +``` + +## File Structure + +``` +. +├── SKILL.md # Main skill guide +├── references/ +│ └── industry-conventions.md # Industry-specific patterns & emotional design +├── INDEX.md # Navigation & quick reference +└── README.md # This file +``` + +## Installation + +### Quick Install (Recommended) +```bash +npx skills add ceorkm/mobile-app-ui-design +``` + +### Manual Installation +1. Clone this repository +2. Copy files to `~/.claude/skills/mobile-app-ui-design/` +3. Restart Claude Code + +### Direct Download +```bash +npx skills add ceorkm/mobile-app-ui-design +``` + +## What's Included + +### SKILL.md +- Core design philosophy +- 5-step design process +- Visual design rules (typography, color, spacing, shadows) +- Smart UI patterns +- Anti-patterns to avoid +- Implementation notes + +### industry-conventions.md +- Industry-specific design languages (AI, Crypto, Finance, Health, etc.) +- Peak-End Rule from Nobel Prize research +- Emotional feedback loops +- Spotify's strategic principles +- 4-phase design process for client work + +### INDEX.md +- Complete navigation with line numbers +- Quick reference guide +- Design laws summary +- Tech stack overview + +## Tech Stack + +The skill provides guidance for: +- React (artifacts) +- Tailwind CSS (utility classes) +- Lucide React (icons) +- Recharts (data visualization) +- CSS transitions (micro-interactions) + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +MIT License - feel free to use this skill in your projects. + +## Credits + +Design principles compiled from industry best practices, research papers, and case studies from leading design teams at: +- Airbnb +- Duolingo +- Spotify +- Revolut +- Phantom +- Apple (Design Award winners) + +--- + +**Made for Claude Code** • [Report Issues](https://github.com/ceorkm/mobile-app-ui-design/issues) diff --git a/.agents/skills/mobile-app-ui-design/SKILL.md b/.agents/skills/mobile-app-ui-design/SKILL.md new file mode 100644 index 0000000..0c6108b --- /dev/null +++ b/.agents/skills/mobile-app-ui-design/SKILL.md @@ -0,0 +1,149 @@ +--- +name: mobile-app-ui-design +description: Design high-quality mobile app UI/UX screens, flows, and components. Use this skill whenever the user asks to design a mobile app screen, create app mockups, build mobile UI components, improve an existing mobile app design, create onboarding flows, design mobile navigation, or requests any mobile-first interface work. Also trigger when the user mentions app design, mobile UI, mobile UX, screen design, app mockups, wireframes, or wants to build React Native / Flutter / SwiftUI style interfaces as visual prototypes. Even if the user just says "design an app" or "make this screen look better", use this skill. +--- + +# Mobile App UI/UX Design Skill + +This skill guides the creation of professional, polished mobile app interfaces that follow proven design principles used by top-tier apps like Airbnb, Duolingo, Spotify, Revolut, and Phantom. + +## Core Philosophy + +Great mobile UI isn't about flashiness — it's about intentionality. Every pixel, every spacing value, every color choice should serve the user. The goal is to create interfaces that feel smooth, personal, and alive — not just functional. + +Before designing anything, understand three things: +1. **What is the user trying to accomplish?** (reduce friction to that goal) +2. **How should this make the user feel?** (trust, delight, confidence, calm) +3. **What's the one thing they should notice first?** (visual hierarchy) + +## Design Process + +Follow this sequence for any mobile screen: + +### Step 1: Understand the Context +- What type of app? (fitness, finance, social, productivity, health, crypto, etc.) +- Who is the user? (new, returning, power user — adapt the experience) +- What's the primary action on this screen? +- What industry design conventions apply? (See `references/industry-conventions.md`) + +### Step 2: Structure First (UX Lens) +- Map the user flow: what screen comes before and after? +- Identify the MVP elements — only what's essential for this screen +- Place primary actions in the **thumb zone** (bottom 1/3 of screen) +- Follow the **F-pattern** reading order for content layout +- Reduce interaction cost: expose content directly instead of hiding behind taps +- Turn empty states into opportunities with guidance, illustration, and a CTA +- Choose the right input method: sliders/scroll wheels for one-time setup, text fields for repeated/precise entry + +### Step 3: Apply Visual Design (UI Lens) +Follow these rules in order: + +#### Typography +- Use **one font family** (two max, with clear hierarchy purpose) +- Maximum **4 font sizes** and **2 font weights** +- Use monospace variants for large numbers (prices, stats, metrics) +- Keep text containers under 600px wide for readability +- Create hierarchy with size, weight, and opacity — not just bold everything + +#### Color System (60/30/10 Rule) +- **60%** — neutral base (white, light gray, or dark background) +- **30%** — complementary color (black text, dark elements) +- **10%** — brand/accent color (CTAs, key indicators, icons) +- Use **opacity variations** of the neutral color for text hierarchy: 100% for headings, 80% for body, 60-70% for secondary text +- Use the accent color at 5% opacity for secondary buttons and subtle card highlights +- Match shadow colors to the background (tint shadows, never pure gray/black on colored backgrounds) +- Save strong colors (like red) for meaningful moments — overuse kills hierarchy + +#### Spacing (8-Point Grid System) +- All spacing values must be divisible by **8 or 4** (8, 12, 16, 24, 32, 48, 64, 80, 96) +- Use **relationship-based spacing**: related elements closer together, unrelated further apart +- Multiplier rule: if related text elements are 16px apart, the gap to the next group should be 2× (32px) +- Section vertical padding: at least 80-96px (160px for major sections on larger screens) +- Card internal padding: 24-32px baseline +- Larger text = larger spacing needed + +#### Shadows +- Always use **soft shadows** — never harsh/distinct +- Match shadow color to the background with a tinted hue +- Use subtle white inner shadows on buttons to add dimension +- Add faded drop shadows for depth without heaviness + +#### Visual Cues & Imagery +- Use icons, emojis, illustrations, and images to make information digestible +- User avatars/photos > initials > generic icons (for representing people) +- Color-coded categories with soft solid backgrounds + clean isolated images +- Keep visual style consistent across the entire app — no random stock photo mix +- Use AI-generated or curated visuals with matching color palettes + +### Step 4: Design for Emotion (Peak-End Rule) +The user will remember two moments: the **peak** (most intense) and the **end** (last impression). + +- **Identify your peak moment**: completing a core task, hitting a milestone, finding what they want +- **Design the peak**: micro-animations, celebratory feedback, sparkles, badges, encouraging copy +- **Design the ending**: summary card, progress affirmation, gentle nudge to return +- Add **emotional feedback loops**: success states should feel rewarding (bounce, glow, sparkle) +- Celebrate small wins — success states don't need to be huge, but they should feel intentional +- Use motion and animation as trust signals, especially in high-stakes domains (finance, crypto, health) + +### Step 5: Polish & Details +- Add subtle glow effects behind key elements (blur + opacity) +- Use tiny white inner shadows on primary buttons +- Add 5% opacity primary-color borders on secondary elements +- Consider micro-animations for state changes +- Ensure all tap targets are at least 44×44pt +- Check contrast ratios for accessibility +- Design error states, empty states, loading states, and success states + +## Smart Patterns to Apply + +### Personalization by User Stage +- **New users**: simple welcome, guided setup, minimal options +- **Returning users**: personalized content, routine-focused, progress indicators +- **Power users**: advanced stats, optimization tools, dense information + +### Smarter Search +Never show a blank search screen. Include: +- Recent searches +- Popular/trending items +- Personalized recommendations + +### Order/Status Tracking +- Open with a confident status message +- Humanize with photos, names, quick-action buttons +- Use visual timelines instead of text-based date lists + +### Category Screens +- Use color-coded cards with soft backgrounds and clean isolated images +- Ensure visual consistency across all category items +- Create rhythm in the layout for effortless scanning + +### Selection Over Manual Input +- Offer tappable selections for common options (job titles, preferences, etc.) +- Include icons/emojis alongside options for personality +- Provide an "Other" option with manual input as fallback + +## Anti-Patterns to Avoid +- Overusing flashy gradients and blur effects (unless you can truly pull it off) +- More than 4 font sizes or 3 font weights +- Random spacing values (use the 8-point grid!) +- Hiding key content behind banners or extra taps +- Placing CTAs outside the thumb zone +- Generic empty states with no guidance +- Using sliders for frequent/precise data entry +- Making all information the same visual weight (no hierarchy) +- Emphasizing labels over values (e.g., making "Sales" bigger than "591") +- Pure gray/black shadows on colored backgrounds + +## Implementation Notes + +When building these designs as React artifacts or HTML: +- Use Tailwind CSS utility classes for spacing, colors, and typography +- Import Lucide React for clean, consistent iconography +- Use Recharts for any data visualization +- Apply CSS transitions for micro-interactions and state changes +- Use CSS variables for the color system +- Mobile-first: design for 375px width (iPhone SE) as baseline +- Use `rounded-2xl` or `rounded-3xl` for modern card aesthetics +- Apply `backdrop-blur` for glassmorphism effects where appropriate + +For deeper guidance on industry-specific conventions and emotional design patterns, read `references/industry-conventions.md`. diff --git a/.agents/skills/mobile-app-ui-design/references/industry-conventions.md b/.agents/skills/mobile-app-ui-design/references/industry-conventions.md new file mode 100644 index 0000000..3333e26 --- /dev/null +++ b/.agents/skills/mobile-app-ui-design/references/industry-conventions.md @@ -0,0 +1,151 @@ +# Industry Conventions & Emotional Design Reference + +## Industry-Specific Design Languages + +Different app categories have established visual conventions that users expect. Understanding these helps you decide when to follow conventions (for familiarity) and when to break them (to stand out). + +### AI / Tech Products +- Soft gradients and moving elements +- Depth and dimensionality with glowing elements +- Smooth animations communicating intelligence +- Dark or light themes with ethereal accent colors + +### Crypto / Web3 +- Futuristic aesthetic: neon colors, bold typography +- Dark mode backgrounds with geometric shapes +- High-contrast, cutting-edge visual language +- Key lesson from Phantom: **polish builds trust** — treat visual details, motion, and transitions as core product features, not fluff + +### Finance / Banking +- Blue-dominant palettes (trust, stability, reliability) +- Generous white space, clean layouts, conservative typography +- Design must feel safe and professional +- Key lesson from Revolut: tactile interactions (draggable charts, 3D card flips) turn basic features into premium experiences + +### Health / Wellness +- Bright, approachable colors to reduce anxiety +- Friendly illustrations and warm micro-interactions +- Non-intimidating onboarding flows +- Key lesson from Ahead (Apple Design Award): guide users kindly, build the "peak" as a personalized insight moment + +### Sleep / Meditation +- Purple and deep blue tones (calming associations) +- Minimal UI, soft transitions +- Ambient, low-contrast interfaces + +### Education / Learning +- Bright, playful color palettes +- Character-driven experiences with personality +- Key lesson from Duolingo: character animations and emotional feedback loops doubled DAUs from 14.2M to 34M+ in 2 years + +### Fitness +- Energetic colors, bold typography +- Progress-focused with visual momentum indicators +- Adapt UI complexity to user stage (new → returning → power user) + +### Productivity +- Clean, minimal, information-dense but organized +- Strong grid systems and consistent spacing +- Quick-action patterns and keyboard shortcuts + +### E-commerce / Food +- High-quality product photography +- Prominent CTAs and frictionless checkout flows +- Trust signals: reviews, ratings, delivery estimates + +--- + +## Emotional Design Principles + +### The Peak-End Rule (from Nobel Prize-winning research by Daniel Kahneman) +The brain doesn't remember an experience as a whole — it compresses it into two moments: +1. **The Peak**: the most intense/emotional moment +2. **The End**: the final impression before leaving + +Disney parks use this — 70% of visitors return not because the whole day was flawless, but because the moments they remember were. + +### Applying Peak-End to Mobile Apps + +**Mapping your journey:** +1. Lay out every step in your core flow (use FigJam, sticky notes, whatever) +2. Ask: Where is the user slowed down? Where might stress peak? Where's the quiet in between? +3. Treat this map as a living document + +**Designing the peak:** +- Pick ONE spot to create a "holy dang" moment +- After completing a core task, hitting a milestone, or investing significant effort +- Can be: a badge, a sparkle animation, surprise copy, a personalized brief that builds in front of them +- Micro-animations and supporting tags showing unique value to the user's specific action + +**Designing the ending:** +- Never let the app just "fall off" without closure +- Celebrate what was done (check mark, summary card) +- Encourage what comes next +- Reaffirm progress ("You showed up today. That's huge.") +- Gentle nudge to return + +**Reducing negative peaks:** +- Look at wait screens, error states, long forms +- Use uplifting microcopy +- Add helpful tools before users even ask +- Use delays as opportunities (loading animations, tips) instead of dead space + +**Testing and iterating:** +- Try timing changes, emojis vs icons, animations vs static feedback +- Watch where people drop off and where they linger longer than expected + +### Emotional Feedback Loops +When users take actions, don't just give functional feedback — give emotional feedback: +- Correct answer → encouragement, cheer, character reaction (not just a green check) +- Mistake → gentle correction with personality (not just a red X) +- Milestone → celebration that feels proportional to the achievement +- Progress → momentum visualization (streaks, levels, completion bars with motion) + +### Three Strategic Principles (from Spotify's playbook) + +**1. The Trojan Horse — Hide complex tech in familiar interfaces** +- Wrap sophisticated features in UI patterns users already know +- Users don't want to interact with algorithms — they want experiences that feel natural +- Ask: "What's the simplest, most familiar way users can interact with this feature?" + +**2. The Vanity Mirror — Make sharing about identity, not the app** +- Create personal insights so meaningful that sharing feels like self-expression +- Don't celebrate what users did in your app — celebrate who they are +- "You're a night owl who does their best work after 9 PM" > "You completed 25 tasks" + +**3. The Comfort Trap — Consistency as a competitive moat** +- Every interaction should follow the same logic and feel like it belongs to the same family +- Predictable patterns become second nature → switching costs increase +- Design consistency isn't about aesthetics — it's about creating habits that competitors can't replicate + +--- + +## Design Process for Client/Product Work + +### Phase 1: Discovery & Inspiration +1. Understand the product fundamentally: what problem, who's it for, what's different +2. Gather UI inspiration (Mobbin, Dribbble, Behance, Twitter, real apps) +3. Organize into mood boards by "vibe" — separate categories for different directions +4. Present 3-5 distinct directions to stakeholders + +### Phase 2: UI Direction (Visual Only) +1. Create 3-5 mockup screens exploring different visual executions +2. Mix and match: client picks button style from A, typography from B, cards from C +3. Finalize one unified visual direction before touching UX + +### Phase 3: UX Structure +1. Brain dump ALL features, then refine to MVP (3-5 core screens) +2. Map screen flows as labeled boxes with arrows +3. Create wireframes (grayscale, structural, no visual design) +4. Iterate with stakeholder feedback + +### Phase 4: Combine UI + UX +1. Apply the finalized visual direction to approved wireframes +2. Check in early — there should be no major surprises +3. Polish: pixel-perfect alignment, all states designed (error, empty, loading, success) + +### Key Separation +- **UI lens**: How do colors work together? What emotions do they evoke? What's the spacing, typography, visual hierarchy? +- **UX lens**: Where's the search bar? Are tap targets big enough? How many steps to complete a core task? Can anything be eliminated? + +Never mix these lenses during research — gather UI and UX inspiration separately. diff --git a/.claude/skills/mobile-app-ui-design/.gitignore b/.claude/skills/mobile-app-ui-design/.gitignore new file mode 100644 index 0000000..33bc2f1 --- /dev/null +++ b/.claude/skills/mobile-app-ui-design/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +*.swp +*.swo +*~ +node_modules/ +.env diff --git a/.claude/skills/mobile-app-ui-design/INDEX.md b/.claude/skills/mobile-app-ui-design/INDEX.md new file mode 100644 index 0000000..0527767 --- /dev/null +++ b/.claude/skills/mobile-app-ui-design/INDEX.md @@ -0,0 +1,131 @@ +# Codebase Index + +## Overview +This repository contains a comprehensive mobile app UI/UX design skill and reference guide focused on creating professional, polished mobile interfaces. + +## File Structure + +``` +mobile-app-ui-design/ +├── SKILL.md # Mobile App UI/UX Design Skill Guide +├── references/ +│ └── industry-conventions.md # Industry-Specific Design Reference +├── INDEX.md # This file +└── README.md # Documentation & Installation +``` + +--- + +## SKILL.md +**Mobile App UI/UX Design Skill** + +A comprehensive guide for designing high-quality mobile app interfaces following proven design principles from top-tier apps. + +### Key Sections: +- **Core Philosophy** (lines 10-18): User-centric design approach +- **Design Process** (lines 20-96): 5-step framework + - Step 1: Understand Context (lines 23-27) + - Step 2: Structure First - UX Lens (lines 29-37) + - Step 3: Visual Design - UI Lens (lines 39-76) + - Typography (lines 41-46) + - Color System - 60/30/10 Rule (lines 48-56) + - Spacing - 8-Point Grid (lines 58-64) + - Shadows (lines 66-69) + - Visual Cues & Imagery (lines 71-77) + - Step 4: Design for Emotion - Peak-End Rule (lines 79-87) + - Step 5: Polish & Details (lines 89-96) +- **Smart Patterns** (lines 98-124): User-stage personalization, search, status tracking, categories +- **Anti-Patterns to Avoid** (lines 126-136): Common design mistakes +- **Implementation Notes** (lines 138-149): Technical guidance for React/Tailwind + +### Design Principles Summary: +- One font family, max 4 sizes, 2 weights +- 60/30/10 color rule +- 8-point grid spacing system +- Soft shadows matched to backgrounds +- Thumb-zone primary actions +- Emotional feedback loops +- Peak-end rule application + +--- + +## references/industry-conventions.md +**Industry Conventions & Emotional Design Reference** + +Deep dive into industry-specific design languages and psychological principles for emotional design. + +### Key Sections: +- **Industry-Specific Design Languages** (lines 3-55) + - AI/Tech (lines 7-11) + - Crypto/Web3 (lines 13-17): Phantom case study + - Finance/Banking (lines 19-23): Revolut case study + - Health/Wellness (lines 25-29): Ahead case study + - Sleep/Meditation (lines 31-34) + - Education/Learning (lines 36-39): Duolingo case study + - Fitness (lines 41-44) + - Productivity (lines 46-49) + - E-commerce/Food (lines 51-54) + +- **Emotional Design Principles** (lines 59-120) + - Peak-End Rule (lines 60-96): Nobel Prize research by Daniel Kahneman + - Emotional Feedback Loops (lines 98-103) + - Three Strategic Principles from Spotify (lines 105-120) + - The Trojan Horse: Hide complexity + - The Vanity Mirror: Identity-based sharing + - The Comfort Trap: Consistency as moat + +- **Design Process for Client/Product Work** (lines 124-152) + - Phase 1: Discovery & Inspiration (lines 126-129) + - Phase 2: UI Direction (lines 131-135) + - Phase 3: UX Structure (lines 137-144) + - Phase 4: Combine UI + UX (lines 146-148) + - UI vs UX lens separation (lines 150-152) + +### Key Insights: +- Industry conventions build familiarity vs. breaking them stands out +- Peak-end rule: users remember the most intense moment + final impression +- Emotional feedback > functional feedback +- Separate UI and UX research lenses + +--- + +## Quick Reference Guide + +### When to Use This Skill +- Designing mobile app screens +- Creating app mockups +- Building mobile UI components +- Improving existing mobile designs +- Creating onboarding flows +- Designing mobile navigation +- Building React Native/Flutter/SwiftUI prototypes + +### Core Design Framework +1. **Understand** → Context, user, primary action, industry conventions +2. **Structure** → User flow, MVP elements, thumb zone, F-pattern +3. **Visual Design** → Typography, color (60/30/10), spacing (8pt grid), shadows +4. **Emotion** → Peak moment, ending, feedback loops +5. **Polish** → Micro-interactions, states, accessibility + +### Design Laws Applied +- **60/30/10 Color Rule**: 60% neutral, 30% complementary, 10% accent +- **8-Point Grid**: All spacing divisible by 8 or 4 +- **Peak-End Rule**: Users remember peak + ending moments +- **Thumb Zone**: Primary actions in bottom 1/3 of screen +- **F-Pattern**: Natural reading/scanning order +- **Typography Hierarchy**: Max 4 sizes, 2 weights, opacity for hierarchy + +--- + +## Implementation Tech Stack +- React (for artifacts) +- Tailwind CSS (utility classes) +- Lucide React (iconography) +- Recharts (data visualization) +- CSS transitions (micro-interactions) +- Mobile-first design (375px baseline) + +--- + +## Tags +`mobile-ui` `ux-design` `emotional-design` `design-system` `react-native` `flutter` `swiftui` `tailwind` `design-principles` `app-design` diff --git a/.claude/skills/mobile-app-ui-design/README.md b/.claude/skills/mobile-app-ui-design/README.md new file mode 100644 index 0000000..a8873fe --- /dev/null +++ b/.claude/skills/mobile-app-ui-design/README.md @@ -0,0 +1,135 @@ +# Mobile App UI/UX Design Skill + +[![Install with npx](https://img.shields.io/badge/install-npx%20skills%20add-blue)](https://skills.sh) +[![Agent Skills](https://img.shields.io/badge/agent-skills-purple)](https://agentskills.io) +[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) + +A comprehensive Claude Code skill for designing professional, polished mobile app interfaces that follow proven design principles from top-tier apps like Airbnb, Duolingo, Spotify, Revolut, and Phantom. + +## Overview + +This skill guides the creation of mobile app UIs that are intentional, smooth, personal, and alive — not just functional. It covers everything from design philosophy to implementation details. + +## Features + +- **Complete Design Process**: 5-step framework from context to polish +- **Industry-Specific Conventions**: Design patterns for 9+ industries (AI, Crypto, Finance, Health, etc.) +- **Emotional Design Principles**: Peak-End Rule, feedback loops, strategic patterns +- **Design Systems**: Typography, color (60/30/10 rule), spacing (8-point grid) +- **Implementation Guidance**: React, Tailwind CSS, component patterns + +## When to Use + +This skill automatically triggers when you ask Claude to: +- Design a mobile app screen +- Create app mockups +- Build mobile UI components +- Improve an existing mobile app design +- Create onboarding flows +- Design mobile navigation +- Build React Native/Flutter/SwiftUI interfaces + +## Design Principles + +### Core Philosophy +Great mobile UI isn't about flashiness — it's about intentionality. Every pixel, spacing value, and color choice should serve the user. + +### Key Design Laws +- **60/30/10 Color Rule**: 60% neutral, 30% complementary, 10% accent +- **8-Point Grid System**: All spacing divisible by 8 or 4 +- **Peak-End Rule**: Users remember peak moment + ending +- **Thumb Zone**: Primary actions in bottom 1/3 of screen +- **F-Pattern**: Natural reading/scanning order +- **Typography Hierarchy**: Max 4 sizes, 2 weights + +## Examples + +``` +"Design a fitness app home screen" +"Create a banking app dashboard" +"Build a meditation app interface" +"Design a crypto wallet transaction screen" +"Create an onboarding flow for a health app" +``` + +## File Structure + +``` +. +├── SKILL.md # Main skill guide +├── references/ +│ └── industry-conventions.md # Industry-specific patterns & emotional design +├── INDEX.md # Navigation & quick reference +└── README.md # This file +``` + +## Installation + +### Quick Install (Recommended) +```bash +npx skills add ceorkm/mobile-app-ui-design +``` + +### Manual Installation +1. Clone this repository +2. Copy files to `~/.claude/skills/mobile-app-ui-design/` +3. Restart Claude Code + +### Direct Download +```bash +npx skills add ceorkm/mobile-app-ui-design +``` + +## What's Included + +### SKILL.md +- Core design philosophy +- 5-step design process +- Visual design rules (typography, color, spacing, shadows) +- Smart UI patterns +- Anti-patterns to avoid +- Implementation notes + +### industry-conventions.md +- Industry-specific design languages (AI, Crypto, Finance, Health, etc.) +- Peak-End Rule from Nobel Prize research +- Emotional feedback loops +- Spotify's strategic principles +- 4-phase design process for client work + +### INDEX.md +- Complete navigation with line numbers +- Quick reference guide +- Design laws summary +- Tech stack overview + +## Tech Stack + +The skill provides guidance for: +- React (artifacts) +- Tailwind CSS (utility classes) +- Lucide React (icons) +- Recharts (data visualization) +- CSS transitions (micro-interactions) + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## License + +MIT License - feel free to use this skill in your projects. + +## Credits + +Design principles compiled from industry best practices, research papers, and case studies from leading design teams at: +- Airbnb +- Duolingo +- Spotify +- Revolut +- Phantom +- Apple (Design Award winners) + +--- + +**Made for Claude Code** • [Report Issues](https://github.com/ceorkm/mobile-app-ui-design/issues) diff --git a/.claude/skills/mobile-app-ui-design/SKILL.md b/.claude/skills/mobile-app-ui-design/SKILL.md new file mode 100644 index 0000000..0c6108b --- /dev/null +++ b/.claude/skills/mobile-app-ui-design/SKILL.md @@ -0,0 +1,149 @@ +--- +name: mobile-app-ui-design +description: Design high-quality mobile app UI/UX screens, flows, and components. Use this skill whenever the user asks to design a mobile app screen, create app mockups, build mobile UI components, improve an existing mobile app design, create onboarding flows, design mobile navigation, or requests any mobile-first interface work. Also trigger when the user mentions app design, mobile UI, mobile UX, screen design, app mockups, wireframes, or wants to build React Native / Flutter / SwiftUI style interfaces as visual prototypes. Even if the user just says "design an app" or "make this screen look better", use this skill. +--- + +# Mobile App UI/UX Design Skill + +This skill guides the creation of professional, polished mobile app interfaces that follow proven design principles used by top-tier apps like Airbnb, Duolingo, Spotify, Revolut, and Phantom. + +## Core Philosophy + +Great mobile UI isn't about flashiness — it's about intentionality. Every pixel, every spacing value, every color choice should serve the user. The goal is to create interfaces that feel smooth, personal, and alive — not just functional. + +Before designing anything, understand three things: +1. **What is the user trying to accomplish?** (reduce friction to that goal) +2. **How should this make the user feel?** (trust, delight, confidence, calm) +3. **What's the one thing they should notice first?** (visual hierarchy) + +## Design Process + +Follow this sequence for any mobile screen: + +### Step 1: Understand the Context +- What type of app? (fitness, finance, social, productivity, health, crypto, etc.) +- Who is the user? (new, returning, power user — adapt the experience) +- What's the primary action on this screen? +- What industry design conventions apply? (See `references/industry-conventions.md`) + +### Step 2: Structure First (UX Lens) +- Map the user flow: what screen comes before and after? +- Identify the MVP elements — only what's essential for this screen +- Place primary actions in the **thumb zone** (bottom 1/3 of screen) +- Follow the **F-pattern** reading order for content layout +- Reduce interaction cost: expose content directly instead of hiding behind taps +- Turn empty states into opportunities with guidance, illustration, and a CTA +- Choose the right input method: sliders/scroll wheels for one-time setup, text fields for repeated/precise entry + +### Step 3: Apply Visual Design (UI Lens) +Follow these rules in order: + +#### Typography +- Use **one font family** (two max, with clear hierarchy purpose) +- Maximum **4 font sizes** and **2 font weights** +- Use monospace variants for large numbers (prices, stats, metrics) +- Keep text containers under 600px wide for readability +- Create hierarchy with size, weight, and opacity — not just bold everything + +#### Color System (60/30/10 Rule) +- **60%** — neutral base (white, light gray, or dark background) +- **30%** — complementary color (black text, dark elements) +- **10%** — brand/accent color (CTAs, key indicators, icons) +- Use **opacity variations** of the neutral color for text hierarchy: 100% for headings, 80% for body, 60-70% for secondary text +- Use the accent color at 5% opacity for secondary buttons and subtle card highlights +- Match shadow colors to the background (tint shadows, never pure gray/black on colored backgrounds) +- Save strong colors (like red) for meaningful moments — overuse kills hierarchy + +#### Spacing (8-Point Grid System) +- All spacing values must be divisible by **8 or 4** (8, 12, 16, 24, 32, 48, 64, 80, 96) +- Use **relationship-based spacing**: related elements closer together, unrelated further apart +- Multiplier rule: if related text elements are 16px apart, the gap to the next group should be 2× (32px) +- Section vertical padding: at least 80-96px (160px for major sections on larger screens) +- Card internal padding: 24-32px baseline +- Larger text = larger spacing needed + +#### Shadows +- Always use **soft shadows** — never harsh/distinct +- Match shadow color to the background with a tinted hue +- Use subtle white inner shadows on buttons to add dimension +- Add faded drop shadows for depth without heaviness + +#### Visual Cues & Imagery +- Use icons, emojis, illustrations, and images to make information digestible +- User avatars/photos > initials > generic icons (for representing people) +- Color-coded categories with soft solid backgrounds + clean isolated images +- Keep visual style consistent across the entire app — no random stock photo mix +- Use AI-generated or curated visuals with matching color palettes + +### Step 4: Design for Emotion (Peak-End Rule) +The user will remember two moments: the **peak** (most intense) and the **end** (last impression). + +- **Identify your peak moment**: completing a core task, hitting a milestone, finding what they want +- **Design the peak**: micro-animations, celebratory feedback, sparkles, badges, encouraging copy +- **Design the ending**: summary card, progress affirmation, gentle nudge to return +- Add **emotional feedback loops**: success states should feel rewarding (bounce, glow, sparkle) +- Celebrate small wins — success states don't need to be huge, but they should feel intentional +- Use motion and animation as trust signals, especially in high-stakes domains (finance, crypto, health) + +### Step 5: Polish & Details +- Add subtle glow effects behind key elements (blur + opacity) +- Use tiny white inner shadows on primary buttons +- Add 5% opacity primary-color borders on secondary elements +- Consider micro-animations for state changes +- Ensure all tap targets are at least 44×44pt +- Check contrast ratios for accessibility +- Design error states, empty states, loading states, and success states + +## Smart Patterns to Apply + +### Personalization by User Stage +- **New users**: simple welcome, guided setup, minimal options +- **Returning users**: personalized content, routine-focused, progress indicators +- **Power users**: advanced stats, optimization tools, dense information + +### Smarter Search +Never show a blank search screen. Include: +- Recent searches +- Popular/trending items +- Personalized recommendations + +### Order/Status Tracking +- Open with a confident status message +- Humanize with photos, names, quick-action buttons +- Use visual timelines instead of text-based date lists + +### Category Screens +- Use color-coded cards with soft backgrounds and clean isolated images +- Ensure visual consistency across all category items +- Create rhythm in the layout for effortless scanning + +### Selection Over Manual Input +- Offer tappable selections for common options (job titles, preferences, etc.) +- Include icons/emojis alongside options for personality +- Provide an "Other" option with manual input as fallback + +## Anti-Patterns to Avoid +- Overusing flashy gradients and blur effects (unless you can truly pull it off) +- More than 4 font sizes or 3 font weights +- Random spacing values (use the 8-point grid!) +- Hiding key content behind banners or extra taps +- Placing CTAs outside the thumb zone +- Generic empty states with no guidance +- Using sliders for frequent/precise data entry +- Making all information the same visual weight (no hierarchy) +- Emphasizing labels over values (e.g., making "Sales" bigger than "591") +- Pure gray/black shadows on colored backgrounds + +## Implementation Notes + +When building these designs as React artifacts or HTML: +- Use Tailwind CSS utility classes for spacing, colors, and typography +- Import Lucide React for clean, consistent iconography +- Use Recharts for any data visualization +- Apply CSS transitions for micro-interactions and state changes +- Use CSS variables for the color system +- Mobile-first: design for 375px width (iPhone SE) as baseline +- Use `rounded-2xl` or `rounded-3xl` for modern card aesthetics +- Apply `backdrop-blur` for glassmorphism effects where appropriate + +For deeper guidance on industry-specific conventions and emotional design patterns, read `references/industry-conventions.md`. diff --git a/.claude/skills/mobile-app-ui-design/references/industry-conventions.md b/.claude/skills/mobile-app-ui-design/references/industry-conventions.md new file mode 100644 index 0000000..3333e26 --- /dev/null +++ b/.claude/skills/mobile-app-ui-design/references/industry-conventions.md @@ -0,0 +1,151 @@ +# Industry Conventions & Emotional Design Reference + +## Industry-Specific Design Languages + +Different app categories have established visual conventions that users expect. Understanding these helps you decide when to follow conventions (for familiarity) and when to break them (to stand out). + +### AI / Tech Products +- Soft gradients and moving elements +- Depth and dimensionality with glowing elements +- Smooth animations communicating intelligence +- Dark or light themes with ethereal accent colors + +### Crypto / Web3 +- Futuristic aesthetic: neon colors, bold typography +- Dark mode backgrounds with geometric shapes +- High-contrast, cutting-edge visual language +- Key lesson from Phantom: **polish builds trust** — treat visual details, motion, and transitions as core product features, not fluff + +### Finance / Banking +- Blue-dominant palettes (trust, stability, reliability) +- Generous white space, clean layouts, conservative typography +- Design must feel safe and professional +- Key lesson from Revolut: tactile interactions (draggable charts, 3D card flips) turn basic features into premium experiences + +### Health / Wellness +- Bright, approachable colors to reduce anxiety +- Friendly illustrations and warm micro-interactions +- Non-intimidating onboarding flows +- Key lesson from Ahead (Apple Design Award): guide users kindly, build the "peak" as a personalized insight moment + +### Sleep / Meditation +- Purple and deep blue tones (calming associations) +- Minimal UI, soft transitions +- Ambient, low-contrast interfaces + +### Education / Learning +- Bright, playful color palettes +- Character-driven experiences with personality +- Key lesson from Duolingo: character animations and emotional feedback loops doubled DAUs from 14.2M to 34M+ in 2 years + +### Fitness +- Energetic colors, bold typography +- Progress-focused with visual momentum indicators +- Adapt UI complexity to user stage (new → returning → power user) + +### Productivity +- Clean, minimal, information-dense but organized +- Strong grid systems and consistent spacing +- Quick-action patterns and keyboard shortcuts + +### E-commerce / Food +- High-quality product photography +- Prominent CTAs and frictionless checkout flows +- Trust signals: reviews, ratings, delivery estimates + +--- + +## Emotional Design Principles + +### The Peak-End Rule (from Nobel Prize-winning research by Daniel Kahneman) +The brain doesn't remember an experience as a whole — it compresses it into two moments: +1. **The Peak**: the most intense/emotional moment +2. **The End**: the final impression before leaving + +Disney parks use this — 70% of visitors return not because the whole day was flawless, but because the moments they remember were. + +### Applying Peak-End to Mobile Apps + +**Mapping your journey:** +1. Lay out every step in your core flow (use FigJam, sticky notes, whatever) +2. Ask: Where is the user slowed down? Where might stress peak? Where's the quiet in between? +3. Treat this map as a living document + +**Designing the peak:** +- Pick ONE spot to create a "holy dang" moment +- After completing a core task, hitting a milestone, or investing significant effort +- Can be: a badge, a sparkle animation, surprise copy, a personalized brief that builds in front of them +- Micro-animations and supporting tags showing unique value to the user's specific action + +**Designing the ending:** +- Never let the app just "fall off" without closure +- Celebrate what was done (check mark, summary card) +- Encourage what comes next +- Reaffirm progress ("You showed up today. That's huge.") +- Gentle nudge to return + +**Reducing negative peaks:** +- Look at wait screens, error states, long forms +- Use uplifting microcopy +- Add helpful tools before users even ask +- Use delays as opportunities (loading animations, tips) instead of dead space + +**Testing and iterating:** +- Try timing changes, emojis vs icons, animations vs static feedback +- Watch where people drop off and where they linger longer than expected + +### Emotional Feedback Loops +When users take actions, don't just give functional feedback — give emotional feedback: +- Correct answer → encouragement, cheer, character reaction (not just a green check) +- Mistake → gentle correction with personality (not just a red X) +- Milestone → celebration that feels proportional to the achievement +- Progress → momentum visualization (streaks, levels, completion bars with motion) + +### Three Strategic Principles (from Spotify's playbook) + +**1. The Trojan Horse — Hide complex tech in familiar interfaces** +- Wrap sophisticated features in UI patterns users already know +- Users don't want to interact with algorithms — they want experiences that feel natural +- Ask: "What's the simplest, most familiar way users can interact with this feature?" + +**2. The Vanity Mirror — Make sharing about identity, not the app** +- Create personal insights so meaningful that sharing feels like self-expression +- Don't celebrate what users did in your app — celebrate who they are +- "You're a night owl who does their best work after 9 PM" > "You completed 25 tasks" + +**3. The Comfort Trap — Consistency as a competitive moat** +- Every interaction should follow the same logic and feel like it belongs to the same family +- Predictable patterns become second nature → switching costs increase +- Design consistency isn't about aesthetics — it's about creating habits that competitors can't replicate + +--- + +## Design Process for Client/Product Work + +### Phase 1: Discovery & Inspiration +1. Understand the product fundamentally: what problem, who's it for, what's different +2. Gather UI inspiration (Mobbin, Dribbble, Behance, Twitter, real apps) +3. Organize into mood boards by "vibe" — separate categories for different directions +4. Present 3-5 distinct directions to stakeholders + +### Phase 2: UI Direction (Visual Only) +1. Create 3-5 mockup screens exploring different visual executions +2. Mix and match: client picks button style from A, typography from B, cards from C +3. Finalize one unified visual direction before touching UX + +### Phase 3: UX Structure +1. Brain dump ALL features, then refine to MVP (3-5 core screens) +2. Map screen flows as labeled boxes with arrows +3. Create wireframes (grayscale, structural, no visual design) +4. Iterate with stakeholder feedback + +### Phase 4: Combine UI + UX +1. Apply the finalized visual direction to approved wireframes +2. Check in early — there should be no major surprises +3. Polish: pixel-perfect alignment, all states designed (error, empty, loading, success) + +### Key Separation +- **UI lens**: How do colors work together? What emotions do they evoke? What's the spacing, typography, visual hierarchy? +- **UX lens**: Where's the search bar? Are tap targets big enough? How many steps to complete a core task? Can anything be eliminated? + +Never mix these lenses during research — gather UI and UX inspiration separately. diff --git a/.gitignore b/.gitignore index 7750600..505d5b7 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,8 @@ local.properties # Impeccable machine-local settings .impeccable/config.local.json .impeccable/questions/ + +# Release signing secrets +keystore.properties +*.jks +*.keystore diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bae7cd4..2e6aa27 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") @@ -32,9 +34,27 @@ android { } } + // Release signing config comes from keystore.properties (git-ignored, never committed). + val keystoreProps = Properties().apply { + val f = rootProject.file("keystore.properties") + if (f.exists()) f.inputStream().use { load(it) } + } + signingConfigs { + if (keystoreProps.isNotEmpty()) { + create("release") { + storeFile = rootProject.file(keystoreProps.getProperty("storeFile")) + storePassword = keystoreProps.getProperty("storePassword") + keyAlias = keystoreProps.getProperty("keyAlias") + keyPassword = keystoreProps.getProperty("keyPassword") + } + } + } + buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true + signingConfigs.findByName("release")?.let { signingConfig = it } proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } @@ -82,7 +102,6 @@ dependencies { implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") implementation("androidx.compose.ui:ui-tooling-preview") - implementation(libs.generativeai) implementation(libs.androidx.ui.test.android) implementation(libs.androidx.material3.android) implementation(libs.androidx.graphics.core) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..c733a77 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,11 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile +# --- Release rules (R8 enabled) --- +# Gson reads these by reflection on field names and generic signatures (tracks.json seeding). +-keepattributes Signature, *Annotation*, InnerClasses, EnclosingMethod +-keep class com.example.trackpro.dataClasses.** { *; } +-keep class com.example.trackpro.online.BundledPremadeTracks$** { *; } +-keep class * extends com.google.gson.reflect.TypeToken +-dontwarn com.google.errorprone.annotations.** diff --git a/app/src/main/java/com/example/trackpro/MainActivity.kt b/app/src/main/java/com/example/trackpro/MainActivity.kt index 946b3e6..f2d0e17 100644 --- a/app/src/main/java/com/example/trackpro/MainActivity.kt +++ b/app/src/main/java/com/example/trackpro/MainActivity.kt @@ -15,6 +15,7 @@ import com.example.trackpro.dataClasses.LapTimeData import com.example.trackpro.dataClasses.SessionData import com.example.trackpro.dataClasses.VehicleInformationData import com.example.trackpro.managerClasses.utilities.timed +import com.example.trackpro.managerClasses.utilities.AppLanguage import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.LaunchedEffect @@ -70,6 +71,7 @@ import com.example.trackpro.screens.profile.ProfileScreen import com.example.trackpro.screens.profile.EditProfileScreen import com.example.trackpro.screens.profile.AccountScreen import com.example.trackpro.screens.history.HistorySection +import com.example.trackpro.screens.drive.DriveScreen import com.example.trackpro.screens.history.HistoryScreen import com.example.trackpro.screens.garage.GarageScreen import com.example.trackpro.dao.VehicleUsage @@ -103,6 +105,9 @@ import com.example.trackpro.managerClasses.SessionManager import com.example.trackpro.online.OnlineServices import com.example.trackpro.online.SyncScheduler import com.example.trackpro.online.ui.LeaderboardScreen +import com.example.trackpro.online.ui.EventsScreen +import com.example.trackpro.online.ui.EventBoardScreen +import androidx.compose.material.icons.filled.Flag import com.example.trackpro.managerClasses.gpsDataManagers.GpsManager import com.example.trackpro.managerClasses.gpsDataManagers.PhoneGpsProvider import com.example.trackpro.models.GpsProviderType @@ -252,6 +257,15 @@ class TrackProApp : Application() { useDarkTheme.value = enabled } + // Paddock night's high-contrast variant, for a phone on a mount in direct sun. + // A separate key: the old dark/light value meant something else and is not carried over. + val useSunlightContrast by lazy { MutableStateFlow(themePrefs.getBoolean("sunlight_contrast", false)) } + + fun setSunlightContrast(enabled: Boolean) { + themePrefs.edit().putBoolean("sunlight_contrast", enabled).apply() + useSunlightContrast.value = enabled + } + // What the live delta is measured against. Remembered, and switchable from the HUD as // well as Settings - reaching Settings from a running session means leaving it. private val deltaPrefs by lazy { getSharedPreferences("delta_prefs", MODE_PRIVATE) } @@ -267,6 +281,22 @@ class TrackProApp : Application() { deltaReference.value = reference } + /** System, English or Hungarian; see [AppLanguage]. */ + val appLanguage by lazy { MutableStateFlow(AppLanguage.stored(this)) } + + fun setAppLanguage(tag: String) { + AppLanguage.store(this, tag) + appLanguage.value = tag + AppLanguage.applyTo(this) + } + + // A system language change resets the application's resources to the phone's + // language; a forced choice has to be laid back over it. + override fun onConfigurationChanged(newConfig: android.content.res.Configuration) { + super.onConfigurationChanged(newConfig) + AppLanguage.applyTo(this) + } + private val unitPrefs by lazy { getSharedPreferences("unit_prefs", MODE_PRIVATE) } val useMetricUnits by lazy { MutableStateFlow(unitPrefs.getBoolean("metric_units", true)) } @@ -287,6 +317,7 @@ class TrackProApp : Application() { override fun onCreate() { super.onCreate() + AppLanguage.applyTo(this) MapLibre.getInstance(this) // Apply a persisted test-server redirect (if any) before the first // connection attempt, so a restart doesn't briefly dial the real ESP32 @@ -319,6 +350,10 @@ class TrackProApp : Application() { class MainActivity : ComponentActivity() { + override fun attachBaseContext(newBase: android.content.Context) { + super.attachBaseContext(AppLanguage.wrap(newBase)) + } + private val locationPermissionRequest = registerForActivityResult( ActivityResultContracts.RequestMultiplePermissions() ) { permissions -> @@ -384,12 +419,12 @@ class MainActivity : ComponentActivity() { setContent { - val useDarkTheme by (application as TrackProApp).useDarkTheme.collectAsState() - TrackProTheme(darkTheme = useDarkTheme) { + val sunlight by (application as TrackProApp).useSunlightContrast.collectAsState() + TrackProTheme(sunlight = sunlight) { val navController = rememberNavController() val backStack by navController.currentBackStackEntryAsState() val currentRoute = backStack?.destination?.route - // The bar belongs to the four tab roots only. Every screen reached from a tab + // The bar belongs to the tab roots only. Every screen reached from a tab // is a step down with its own back, and the HUDs must never carry it. val selectedTab = MainTabs.firstOrNull { tab -> currentRoute?.substringBefore('?') == tab.route } @@ -408,10 +443,10 @@ class MainActivity : ComponentActivity() { modifier = Modifier.weight(1f) ) { composable("main") { - DashBoard( - onNavigateToDragRace = { navController.navigate("drag") }, - onNavigateToTrackVehicleSelector = { navController.navigate("trackandvehicle") }, - onNavigateToESPTestScreen = { navController.navigate("esptest") }, + DriveScreen( + onStartTrack = { navController.navigate("trackandvehicle") }, + onStartDrag = { navController.navigate("drag") }, + onOpenRig = { navController.navigate("esptest") }, onOpenHistory = { section -> openTab("history?section=$section") }, onOpenCar = { id -> navController.navigate("vehicle/$id") }, onAddCar = { navController.navigate("createvehicle") } @@ -437,6 +472,21 @@ class MainActivity : ComponentActivity() { trackViewModel = trackViewModel ) } + composable("events") { + EventsScreen( + onOpenEvent = { id -> navController.navigate("event/$id") }, + onSignIn = { navController.navigate("account") } + ) + } + composable( + "event/{eventId}", + arguments = listOf(navArgument("eventId") { type = NavType.StringType }) + ) { entry -> + EventBoardScreen( + eventId = entry.arguments?.getString("eventId").orEmpty(), + onBack = { navController.popBackStack() } + ) + } composable("profile") { ProfileScreen(navController = navController) } @@ -547,231 +597,11 @@ class MainActivity : ComponentActivity() { } } -/** The four places the app has. Everything else is reached from one of them. */ +/** The places the app has. Everything else is reached from one of them. */ private val MainTabs = listOf( - DashTab("main", "Drive", Icons.Default.Speed), - DashTab("history", "History", Icons.Default.History), - DashTab("garage", "Garage", Icons.Default.DirectionsCar), - DashTab("profile", "Profile", Icons.Default.Person), + DashTab("main", R.string.tab_drive, Icons.Default.Speed), + DashTab("history", R.string.tab_history, Icons.Default.History), + DashTab("events", R.string.tab_events, Icons.Default.Flag), + DashTab("garage", R.string.tab_garage, Icons.Default.DirectionsCar), + DashTab("profile", R.string.tab_profile, Icons.Default.Person), ) - -// ── Action card ──────────────────────────────────────────── - -// ── Status board ──────────────────────────────────── - -/** - * The dash at rest. - * - * This screen is read parked, never at speed, so it carries no at-speed legibility tax - - * that constraint belongs to the two HUDs alone. What it owes instead is that you learn - * something every time you open it: what the rig is doing right now, and what the last - * session did. - * - * Only two things earned a place here, and both are live from the database. There is no - * lifetime-totals wall and no per-track record table; they were considered and cut, - * because a number you never act on is decoration with a value in it. - * - * A fresh install renders the identical panel with dashes on every face. That is the - * whole empty state - no separate onboarding screen to design, maintain, or drift. - */ -@Composable -private fun DashBoard( - onNavigateToDragRace: () -> Unit, - onNavigateToTrackVehicleSelector: () -> Unit, - onNavigateToESPTestScreen: () -> Unit, - onOpenHistory: (section: String) -> Unit, - onOpenCar: (Long) -> Unit, - onAddCar: () -> Unit, -) { - val context = LocalContext.current - val app = context.applicationContext as TrackProApp - - val isConnected by app.gpsManager.connectionStatus.collectAsState(initial = false) - val vehicles by app.database.vehicleInformationDAO().getAllVehicles().collectAsState(initial = emptyList()) - val usage by remember { app.database.vehicleInformationDAO().observeUsage() }.collectAsState(initial = emptyList()) - val sessions by app.database.sessionDataDao().getAllSessions().collectAsState(initial = emptyList()) - - // The most recent session and its best lap. Resolved off the main thread; until it - // lands every face shows a dash, which is also the honest empty state. - var lastSession by remember { mutableStateOf(null) } - var lastBestLap by remember { mutableStateOf(null) } - var lastLapCount by remember { mutableStateOf(0) } - var lastTrackName by remember { mutableStateOf(null) } - - LaunchedEffect(sessions) { - // The most recent session that still counts. A voided run stays in the archive - // and stays openable, but the panel does not report it as what you last did. - val recent = sessions.filter { !it.voided }.maxByOrNull { it.startTime } - lastSession = recent - if (recent == null) return@LaunchedEffect - withContext(Dispatchers.IO) { - // timed() rather than filtering on the IN PROGRESS string: that filter missed - // INVALID laps, which then parsed as 00:00.00 and showed up here as the best lap. - val done = app.database.lapTimeDataDAO().getLapsForSession(recent.id).timed() - lastLapCount = done.size - lastBestLap = done.minByOrNull { it.millis }?.lap - lastTrackName = recent.trackId?.let { id -> - runCatching { app.database.trackMainDao().getTrack(id).first().trackName }.getOrNull() - } - } - } - - // The car you drive most, not whichever row happens to be first in the table. - val mainId = usage.maxWithOrNull(compareBy({ it.sessions }, { it.lastUsed ?: 0L }))?.vehicleId - val vehicle: VehicleInformationData? = vehicles.firstOrNull { it.vehicleId == mainId } ?: vehicles.firstOrNull() - val dash = "\u2014" - - Column( - modifier = Modifier - .fillMaxSize() - .background(TrackProTheme.colors.panel) - .verticalScroll(rememberScrollState()) - ) { - - // ── Link bar ─────────────────────────────────── - // The rig's state as the panel's top edge, the way a dash puts its shift lights - // there: a strip you catch without looking at it directly. - Row( - modifier = Modifier - .fillMaxWidth() - // The whole strip opens the rig page: it is the rig's lamp, so it is the way in. - .pressableRow(onClick = onNavigateToESPTestScreen) - .padding(horizontal = 14.dp, vertical = 17.dp), - verticalAlignment = Alignment.CenterVertically - ) { - SegmentBar( - signedFraction = if (isConnected) 1f else 0f, - activeColor = if (isConnected) TrackProTheme.colors.deltaGood - else TrackProTheme.colors.segmentOff, - bidirectional = false, - segments = 16, - height = 10.dp, - modifier = Modifier.weight(1f) - ) - Spacer(Modifier.width(12.dp)) - Text( - text = if (isConnected) "LINKED" else "NO SIGNAL", - style = TrackProType.label, - // Fault Is Never Dimmer, and the same word the HUD uses - the board said - // NO LINK in markingDim while the HUD said NO SIGNAL in textFaint, a third - // treatment of one concept. - color = if (isConnected) TrackProTheme.colors.deltaGood - else TrackProTheme.colors.danger - ) - } - - Bezel() - - // ── Mode entry ───────────────────────────────── - // The two things this app exists to do, first on the panel and the only elements - // carrying a lit ground and an accent hairline. Everything below is a readout. - Column( - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.panel) - .padding(horizontal = 12.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - DashAction( - label = "Track", - detail = "Circuit · sprint · live delta", - onClick = onNavigateToTrackVehicleSelector - ) - DashAction( - label = "Drag", - detail = "0–100 · quarter mile · splits", - onClick = onNavigateToDragRace - ) - } - - Bezel() - - // ── Car placard ──────────────────────────────── - Row( - modifier = Modifier - .fillMaxWidth() - .pressableRow(onClick = { if (vehicle != null) onOpenCar(vehicle.vehicleId) else onAddCar() }) - .padding(horizontal = 12.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - PhotoFrame( - file = app.online.photos.file(vehicle?.photoFile), - contentDescription = vehicle?.let { "${it.manufacturer} ${it.model}" }, - modifier = Modifier.size(width = 80.dp, height = 60.dp) - ) - Spacer(Modifier.width(14.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = if (vehicle != null) "${vehicle.manufacturer} ${vehicle.model}".uppercase() - else "NO VEHICLE", - style = TrackProType.titleLarge.atSize(20.sp), - color = TrackProTheme.colors.marking - ) - Spacer(Modifier.height(3.dp)) - Text( - text = if (vehicle != null) listOfNotNull( - vehicle.year.takeIf { it > 0 }?.toString(), - vehicle.horsepower.takeIf { it > 0 }?.let { "$it HP" }, - vehicle.drivetrain.takeIf { it.isNotBlank() }, - vehicle.weight.takeIf { it > 0.0 }?.let { "${it.toInt()} KG" } - ).joinToString(" · ") else "Add one to record a session", - style = TrackProType.label, - color = TrackProTheme.colors.markingDim - ) - } - } - - Bezel() - - // ── Last session ─────────────────────────────── - Column( - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.field) - .padding(horizontal = 12.dp, vertical = 14.dp) - ) { - Readout( - value = lastBestLap?.laptime ?: dash, - caption = "Best lap · last session", - valueColor = if (lastBestLap != null) TrackProTheme.colors.accent - else TrackProTheme.colors.markingDim, - valueSize = 46.sp - ) - Spacer(Modifier.height(10.dp)) - Row(modifier = Modifier.fillMaxWidth()) { - Text( - text = (lastTrackName ?: lastSession?.eventType ?: "No sessions recorded").uppercase(), - style = TrackProType.label, - color = TrackProTheme.colors.markingDim, - modifier = Modifier.weight(1f) - ) - Text( - text = if (lastSession != null) "$lastLapCount LAPS" else dash, - style = TrackProType.label, - color = TrackProTheme.colors.markingDim - ) - } - } - - Bezel() - - // ── Secondary ────────────────────────────────── - // Counts that open the History tab. Tracks, the builder and Setup used to share - // this row; they now live in Garage and Profile, with the rest of their kind. - Row(modifier = Modifier.fillMaxWidth()) { - Instrument( - label = "Track sessions", - value = "${sessions.count { it.trackId != null && it.trackId != -1L && !it.voided }}", - modifier = Modifier.weight(1f).pressableRow(onClick = { onOpenHistory("track") }) - ) - Bezel(vertical = true, modifier = Modifier.height(58.dp)) - Instrument( - label = "Drag runs", - value = "${sessions.count { (it.trackId == null || it.trackId == -1L) && !it.voided }}", - modifier = Modifier.weight(1f).pressableRow(onClick = { onOpenHistory("drag") }) - ) - } - Bezel() - Spacer(Modifier.height(20.dp)) - } -} diff --git a/app/src/main/java/com/example/trackpro/components/AppCard.kt b/app/src/main/java/com/example/trackpro/components/AppCard.kt index 3a391a1..40be75a 100644 --- a/app/src/main/java/com/example/trackpro/components/AppCard.kt +++ b/app/src/main/java/com/example/trackpro/components/AppCard.kt @@ -1,7 +1,5 @@ package com.example.trackpro.components -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.padding @@ -9,31 +7,21 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import com.example.trackpro.extrasForUI.TrackProTheme import com.example.trackpro.theme.Spacing import com.example.trackpro.theme.bezel -import com.example.trackpro.theme.field -/** - * A field on the panel. - * - * Not a card: no radius, no elevation, no shadow. An instrument field is distinguished - * from the panel by its ground and a milled hairline, which is the only containment the - * dash world has. The name is kept so the screens that already call it inherit the world - * without a rename sweep. - */ +/** A raised card. [borderColor] is accepted for older call sites and no longer drawn. */ @Composable fun AppCard( modifier: Modifier = Modifier, - padding: Dp = Spacing.md, - borderColor: Color = TrackProTheme.colors.bezel, + padding: Dp = Spacing.lg, + @Suppress("UNUSED_PARAMETER") borderColor: Color = TrackProTheme.colors.bezel, content: @Composable ColumnScope.() -> Unit ) { Column( modifier = modifier - .background(TrackProTheme.colors.field) - .border(width = 1.dp, color = borderColor) + .paddockCard() .padding(padding), content = content ) diff --git a/app/src/main/java/com/example/trackpro/components/AppTopBar.kt b/app/src/main/java/com/example/trackpro/components/AppTopBar.kt index 25f38a4..24b3e08 100644 --- a/app/src/main/java/com/example/trackpro/components/AppTopBar.kt +++ b/app/src/main/java/com/example/trackpro/components/AppTopBar.kt @@ -1,112 +1,115 @@ package com.example.trackpro.components +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material3.HorizontalDivider +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.example.trackpro.extrasForUI.TrackProTheme -import com.example.trackpro.theme.atSize -import com.example.trackpro.theme.markingDim +import com.example.trackpro.theme.Spacing import com.example.trackpro.theme.TrackProType +import com.example.trackpro.theme.fieldLive +import com.example.trackpro.theme.marking +import com.example.trackpro.theme.markingDim +import com.example.trackpro.theme.panel -/** Height of the bar itself, exported so [ScreenScaffold] can inset content by it. */ -val AppTopBarHeight = 48.dp +/** Height of the bar, exported so [ScreenScaffold] can inset content by it. */ +val AppTopBarHeight = 64.dp /** - * Compact 48dp header shared by every screen. The section accent shows up only as a - * small dot next to the title, never as a full-bleed fill. + * The screen's header: a round back button, the title in sentence case, and room for one + * trailing element. It sits on the page ground rather than a bar of its own, so the cards + * below read as the content and the header as the page's name. * - * [containerColor] and [showDivider] exist so [ScreenScaffold] can host this as a - * floating translucent layer - it draws the material and the scroll-edge fade itself, so - * it hands the bar a transparent container and suppresses the hard divider. Screens - * should generally use [ScreenScaffold] rather than placing this directly. + * [accent] and [showDivider] are accepted for older call sites and no longer drawn. */ @Composable fun AppTopBar( title: String, modifier: Modifier = Modifier, - accent: Color = TrackProTheme.colors.textMuted, + @Suppress("UNUSED_PARAMETER") accent: Color = TrackProTheme.colors.textMuted, subtitle: String? = null, onBack: (() -> Unit)? = null, - containerColor: Color = TrackProTheme.colors.bgCard, - showDivider: Boolean = true, + containerColor: Color = TrackProTheme.colors.panel, + @Suppress("UNUSED_PARAMETER") showDivider: Boolean = true, trailing: @Composable (() -> Unit)? = null ) { - Column(modifier = modifier.fillMaxWidth()) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(containerColor) - .height(AppTopBarHeight) - // Back is the primary nav control on nearly every screen, so it gets a - // full 48dp target. The bar is exactly 48dp tall, so IconButton's default - // size fits flush; the reduced start inset keeps the arrow optically in - // the same place it sat when the button was a (too small) 32dp box. - .padding(start = if (onBack != null) 0.dp else 12.dp, end = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (onBack != null) { - IconButton(onClick = onBack) { + Row( + modifier = modifier + .fillMaxWidth() + .background(containerColor) + .height(AppTopBarHeight) + .padding(start = if (onBack != null) Spacing.sm else Spacing.gutter, end = Spacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + if (onBack != null) { + Box( + modifier = Modifier + .size(48.dp) + .pressable(onClick = onBack, scale = 0.92f), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(TrackProTheme.colors.fieldLive), + contentAlignment = Alignment.Center + ) { Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = "Back", - tint = TrackProTheme.colors.textPrimary, - modifier = Modifier.size(18.dp) + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.common_back), + tint = TrackProTheme.colors.marking, + modifier = Modifier.size(20.dp) ) } } + Spacer(Modifier.width(Spacing.sm)) + } - Box( - modifier = Modifier - .size(6.dp) - .background(accent, CircleShape) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = TrackProType.titleLarge, + color = TrackProTheme.colors.marking, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - - Column( - modifier = Modifier - .padding(start = 10.dp) - .weight(1f) - ) { + if (subtitle != null) { Text( - text = title.uppercase(), - style = TrackProType.label.atSize(12.sp), - color = TrackProTheme.colors.textPrimary + text = subtitle, + style = TrackProType.label, + color = TrackProTheme.colors.markingDim, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - if (subtitle != null) { - Text( - text = subtitle, - style = TrackProType.body.atSize(11.sp), - color = TrackProTheme.colors.markingDim - ) - } } + } - if (trailing != null) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) { - trailing() - } + if (trailing != null) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) { + trailing() } } - if (showDivider) { - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) - } } } diff --git a/app/src/main/java/com/example/trackpro/components/ConfirmDeleteDialog.kt b/app/src/main/java/com/example/trackpro/components/ConfirmDeleteDialog.kt index 2835c98..bc39853 100644 --- a/app/src/main/java/com/example/trackpro/components/ConfirmDeleteDialog.kt +++ b/app/src/main/java/com/example/trackpro/components/ConfirmDeleteDialog.kt @@ -1,5 +1,7 @@ package com.example.trackpro.components +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -25,8 +27,8 @@ fun ConfirmDeleteDialog( message: String, onConfirm: () -> Unit, onDismiss: () -> Unit, - confirmLabel: String = "Delete", - dismissLabel: String = "Cancel" + confirmLabel: String = stringResource(R.string.common_delete), + dismissLabel: String = stringResource(R.string.common_cancel) ) { val haptics = rememberHaptics() diff --git a/app/src/main/java/com/example/trackpro/components/Dash.kt b/app/src/main/java/com/example/trackpro/components/Dash.kt index 71969d1..1197293 100644 --- a/app/src/main/java/com/example/trackpro/components/Dash.kt +++ b/app/src/main/java/com/example/trackpro/components/Dash.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row @@ -15,9 +16,15 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowForward +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -27,52 +34,151 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.trackpro.extrasForUI.TrackProTheme import com.example.trackpro.theme.Motion +import com.example.trackpro.theme.Spacing +import com.example.trackpro.theme.TrackProShapes import com.example.trackpro.theme.TrackProType +import com.example.trackpro.theme.accentGlow import com.example.trackpro.theme.atSize import com.example.trackpro.theme.bezel +import com.example.trackpro.theme.cardShadow import com.example.trackpro.theme.field -import com.example.trackpro.theme.fieldLive +import com.example.trackpro.theme.highlight import com.example.trackpro.theme.marking -import com.example.trackpro.theme.panel import com.example.trackpro.theme.markingDim import com.example.trackpro.theme.segmentOff import kotlin.math.abs -import kotlin.math.roundToInt + +// ── Surfaces ────────────────────────────────────────────────── /** - * The dash's one bar language: discrete lit segments, never a gradient or a smooth fill. - * - * A purpose-built display uses segments because a driver counts blocks in peripheral - * vision far faster than they judge the length of a continuous bar - the quantisation is - * the feature. Unlit segments are drawn rather than omitted so the bar's full range stays - * visible and a value near zero still reads as "near zero" rather than "no data". + * The raised card every screen is built from: rounded, a soft shadow in the ground's own + * hue, and a hairline highlight across the top edge that catches light like a pressed panel. + */ +fun Modifier.paddockCard( + shape: Shape = TrackProShapes.card, + color: Color? = null, + elevation: Dp = 10.dp, +): Modifier = composed { + val colors = TrackProTheme.colors + Modifier + .shadow(elevation, shape, clip = false, ambientColor = colors.cardShadow, spotColor = colors.cardShadow) + .clip(shape) + .background(color ?: colors.field) + .border(1.dp, Brush.verticalGradient(listOf(colors.highlight, Color.Transparent)), shape) +} + +/** A card container with default padding. */ +@Composable +fun PaddockCard( + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, + padding: Dp = Spacing.lg, + color: Color? = null, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.pressable(onClick = onClick, onLongClick = onLongClick, scale = 0.98f) else Modifier) + .paddockCard(color = color) + .padding(padding), + content = content + ) +} + +/** A section's own title, sentence case, with an optional action on the right. */ +@Composable +fun SectionTitle( + text: String, + modifier: Modifier = Modifier, + action: String? = null, + onAction: (() -> Unit)? = null, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(start = Spacing.gutter, end = Spacing.sm, top = Spacing.xl, bottom = Spacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Text(text, style = TrackProType.titleMedium, color = TrackProTheme.colors.marking, modifier = Modifier.weight(1f)) + if (action != null && onAction != null) { + Text( + action, + style = TrackProType.label.copy(fontWeight = TrackProType.titleMedium.fontWeight), + color = TrackProTheme.colors.accent, + modifier = Modifier + .pressable(onClick = onAction, scale = 0.96f) + .heightIn(min = 44.dp) + .wrapContentHeight(Alignment.CenterVertically) + .padding(horizontal = Spacing.sm) + ) + } + } +} + +/** An icon on a soft circle of its own tint - the category mark of a row or tile. */ +@Composable +fun IconCircle( + icon: ImageVector, + modifier: Modifier = Modifier, + tint: Color = TrackProTheme.colors.accent, + size: Dp = 40.dp, + filled: Boolean = false, +) { + Box( + modifier = modifier + .size(size) + .clip(CircleShape) + .background(if (filled) tint else tint.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center + ) { + Icon( + icon, + contentDescription = null, + tint = if (filled) TrackProTheme.colors.onAccent else tint, + modifier = Modifier.size(size * 0.5f) + ) + } +} + +// ── Bars ────────────────────────────────────────────────────── + +/** + * A rounded bar for a live value. * * [signedFraction] runs -1..1 in bidirectional mode. **Positive fills right and means - * better** - faster, gaining - which is the orientation this app settled on: gaining - * pushes the bar forward. Callers with a lower-is-better value (a lap delta) pass its - * negation. In unidirectional mode the range is 0..1 filling from the left. - * - * The fill is spring-damped. Live GPS delta is noisy enough that an undamped bar strobes, - * and a snapping readout is a lie about how confidently the value is known. + * better** - faster, gaining - so callers with a lower-is-better value (a lap delta) pass its + * negation. In unidirectional mode the range is 0..1 from the left. The fill is + * spring-damped: live GPS is noisy enough that an undamped bar strobes. [segments] is kept + * for older call sites and no longer changes the drawing. */ @Composable fun SegmentBar( signedFraction: Float, activeColor: Color, modifier: Modifier = Modifier, - segments: Int = 21, + @Suppress("UNUSED_PARAMETER") segments: Int = 21, bidirectional: Boolean = true, - height: Dp = 14.dp, + height: Dp = 12.dp, showDatum: Boolean = true ) { val reducedMotion = rememberReducedMotion() @@ -80,54 +186,57 @@ fun SegmentBar( val animated by animateFloatAsState( targetValue = target, animationSpec = if (reducedMotion) snap() else Motion.standard(), - label = "segmentFill" + label = "barFill" ) + val track = TrackProTheme.colors.segmentOff + val datum = TrackProTheme.colors.marking - // An odd segment count gives a true centre block to sit the datum on. - val count = if (bidirectional && segments % 2 == 0) segments + 1 else segments - val centre = count / 2 - val lit = (abs(animated) * (if (bidirectional) centre else count)).roundToInt() - - Row( + BoxWithConstraints( modifier = modifier .fillMaxWidth() - .height(height), - horizontalArrangement = Arrangement.spacedBy(2.dp) + .height(height) + .clip(TrackProShapes.pill) + .background(track) ) { - repeat(count) { i -> - val isCentre = bidirectional && i == centre - val on = if (bidirectional) { - if (animated >= 0f) i > centre && i <= centre + lit - else i < centre && i >= centre - lit - } else { - i < lit - } - val color = when { - on -> activeColor - isCentre && showDatum -> TrackProTheme.colors.marking - else -> TrackProTheme.colors.segmentOff + val full = maxWidth + if (bidirectional) { + val half = full / 2 + val fill = half * abs(animated) + Box( + Modifier + .offset(x = if (animated >= 0f) half else half - fill) + .width(fill) + .fillMaxHeight() + .clip(TrackProShapes.pill) + .background(activeColor) + ) + if (showDatum) { + Box( + Modifier + .offset(x = half - 1.5.dp) + .width(3.dp) + .fillMaxHeight() + .background(datum) + ) } + } else { Box( - modifier = Modifier - .weight(if (isCentre && showDatum) 0.6f else 1f) + Modifier + .width(full * animated) .fillMaxHeight() - .background(color) + .clip(TrackProShapes.pill) + .background(activeColor) ) } } } +// ── Trend ───────────────────────────────────────────────────── + /** - * Which way a value is moving, with hysteresis. - * - * A trend mark that flips every frame is worse than no mark: it reads as noise and trains - * the eye to ignore that corner of the panel. [threshold] is how far the value must travel - * from the last committed reading before the direction is allowed to change, so a delta - * jittering by milliseconds holds Steady while a real gain flips it. - * - * This is the six-pack discipline the direction contract took: an instrument that shows - * only its current value says where you are and not where you are going, and on a lap the - * second one is what changes your driving. + * Which way a value is moving, with hysteresis: [threshold] is how far it must travel from + * the last committed reading before the direction may change, so millisecond jitter holds + * Steady while a real gain flips it. */ @Composable fun rememberTrend(value: Float, threshold: Float): Trend { @@ -143,24 +252,17 @@ fun rememberTrend(value: Float, threshold: Float): Trend { return trend } -/** Which way a value is moving. Drawn, never a glyph. */ enum class Trend { Rising, Falling, Steady } -/** - * A trend mark. - * - * The six-pack discipline the direction inherited: an instrument that shows only its - * current value tells you where you are but not where you are going, and on a lap the - * second one is what changes your driving. - */ @Composable private fun TrendMark(trend: Trend, color: Color, modifier: Modifier = Modifier) { - Canvas(modifier = modifier.size(width = 8.dp, height = 6.dp)) { + Canvas(modifier = modifier.size(width = 10.dp, height = 8.dp)) { if (trend == Trend.Steady) { - drawRect( + drawRoundRect( color = color, - topLeft = Offset(0f, size.height / 2f - 0.75.dp.toPx()), - size = androidx.compose.ui.geometry.Size(size.width, 1.5.dp.toPx()) + topLeft = Offset(0f, size.height / 2f - 1.dp.toPx()), + size = androidx.compose.ui.geometry.Size(size.width, 2.dp.toPx()), + cornerRadius = androidx.compose.ui.geometry.CornerRadius(1.dp.toPx()) ) return@Canvas } @@ -176,12 +278,11 @@ private fun TrendMark(trend: Trend, color: Color, modifier: Modifier = Modifier) } } +// ── Stats ───────────────────────────────────────────────────── + /** - * One instrument: a placard cap, a value, and optionally where that value is heading. - * - * This is the dash's only container. There are no cards - a field is distinguished by its - * ground and a bezel hairline, the way a panel is milled rather than the way a web page - * stacks boxes. + * A stat: a small caption and its value. The value leads visually - it is the larger, brighter + * of the two - so a screen never makes "Laps" louder than "342". */ @Composable fun Instrument( @@ -194,70 +295,61 @@ fun Instrument( alignEnd: Boolean = false ) { Column( - modifier = modifier - .background(TrackProTheme.colors.field) - .padding(horizontal = 12.dp, vertical = 10.dp), + modifier = modifier.padding(horizontal = Spacing.lg, vertical = Spacing.md), horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start ) { - Text( - text = label.uppercase(), - style = TrackProType.label, - color = TrackProTheme.colors.markingDim - ) - Spacer(Modifier.height(3.dp)) + Text(text = label, style = TrackProType.label, color = TrackProTheme.colors.markingDim, maxLines = 1) + Spacer(Modifier.height(4.dp)) Row(verticalAlignment = Alignment.CenterVertically) { if (trend != null && alignEnd) { TrendMark(trend, valueColor ?: TrackProTheme.colors.marking) - Spacer(Modifier.width(5.dp)) + Spacer(Modifier.width(6.dp)) } Text( text = value, style = TrackProType.statValue.atSize(valueSize), color = valueColor ?: TrackProTheme.colors.marking, - textAlign = if (alignEnd) TextAlign.End else TextAlign.Start + textAlign = if (alignEnd) TextAlign.End else TextAlign.Start, + maxLines = 1 ) if (trend != null && !alignEnd) { - Spacer(Modifier.width(5.dp)) + Spacer(Modifier.width(6.dp)) TrendMark(trend, valueColor ?: TrackProTheme.colors.marking) } } } } -/** A milled hairline between fields. The panel's only divider. */ +/** A hairline divider inside a card. */ @Composable fun Bezel(modifier: Modifier = Modifier, vertical: Boolean = false) { Box( modifier = modifier - .then(if (vertical) Modifier.width(1.dp).fillMaxHeight() else Modifier.fillMaxWidth().height(1.dp)) + .then(if (vertical) Modifier.width(1.dp).fillMaxHeight().padding(vertical = 10.dp) else Modifier.fillMaxWidth().height(1.dp)) .background(TrackProTheme.colors.bezel) ) } -/** - * The dominant readout: one number, sized to be caught peripherally. - * - * [caption] is the placard beneath it. The number is never centred under a heading - it - * leads, and the placard explains it afterwards, because at speed the value is read first - * and identified second. - */ +/** The screen's one headline number, with its caption beneath. */ @Composable fun Readout( value: String, caption: String, modifier: Modifier = Modifier, valueColor: Color? = null, - valueSize: TextUnit = 56.sp, + valueSize: TextUnit = 40.sp, trend: Trend? = null, trailing: (@Composable () -> Unit)? = null ) { Column(modifier = modifier) { + Text(text = caption, style = TrackProType.label, color = TrackProTheme.colors.markingDim) + Spacer(Modifier.height(4.dp)) Row(verticalAlignment = Alignment.Bottom) { if (trend != null) { TrendMark( trend = trend, color = valueColor ?: TrackProTheme.colors.marking, - modifier = Modifier.padding(end = 8.dp, bottom = 10.dp) + modifier = Modifier.padding(end = 8.dp, bottom = 12.dp) ) } Text( @@ -271,25 +363,19 @@ fun Readout( trailing() } } - Text( - text = caption.uppercase(), - style = TrackProType.label, - color = TrackProTheme.colors.markingDim - ) } } +// ── Actions ─────────────────────────────────────────────────── + /** - * A lit block: the dash's only promoted control. + * An action tile. * - * Everything else on a panel is a readout you look at; this is the one thing you press. - * It earns its weight from a lit ground plus a hairline in the accent, not from a shadow - * or a rounded card - the world has neither. Corners stay square because a milled panel - * has square apertures. - * - * Disabled is drawn, not hidden: the block stays, the border drops to the bezel, and the - * segment strip unlights. A control that vanishes when unavailable teaches nothing about - * what would make it available. + * [primary] is the screen's one orange, filled block with a soft orange glow beneath it - + * at most one per screen. Everything else is secondary: a card-toned tile whose icon and + * label carry the accent at low strength. [accent] overrides the tint, e.g. red for a stop. + * [compact] is a 52dp button for toolbars and pairs; the full tile is 96dp with room for an + * icon and a detail line. */ @Composable fun DashAction( @@ -298,72 +384,82 @@ fun DashAction( modifier: Modifier = Modifier, detail: String? = null, enabled: Boolean = true, - labelSize: TextUnit = 34.sp, + @Suppress("UNUSED_PARAMETER") labelSize: TextUnit = 20.sp, accent: Color? = null, - /** Inline control: shorter, centred, no segment strip. For toolbars and pairs. */ compact: Boolean = false, - /** Only for genuine commits - recording started, a sector marked, a track saved. */ - haptic: Haptic? = null + haptic: Haptic? = null, + primary: Boolean = false, + icon: ImageVector? = null, ) { - val lit = accent ?: TrackProTheme.colors.accent - val edge = if (enabled) lit else TrackProTheme.colors.bezel - val ink = if (enabled) TrackProTheme.colors.marking else TrackProTheme.colors.markingDim + val tint = accent ?: TrackProTheme.colors.accent + val onTint = if (accent == null) TrackProTheme.colors.onAccent else Color.White + val shape = if (compact) TrackProShapes.control else TrackProShapes.card + val glow = TrackProTheme.colors.accentGlow + val ink = if (primary) onTint else TrackProTheme.colors.marking + val sub = if (primary) onTint.copy(alpha = 0.72f) else TrackProTheme.colors.markingDim Row( modifier = modifier .fillMaxWidth() - .pressableRow(onClick = onClick, enabled = enabled, haptic = haptic) - .background(if (enabled) TrackProTheme.colors.fieldLive else TrackProTheme.colors.field) - .border(1.dp, edge) - .heightIn(min = if (compact) 48.dp else 72.dp) - .padding(horizontal = 16.dp, vertical = if (compact) 10.dp else 16.dp), + .alpha(if (enabled) 1f else 0.4f) + .pressable(onClick = onClick, enabled = enabled, haptic = haptic, scale = 0.97f) + .then( + if (primary) Modifier + .shadow(if (enabled) 18.dp else 0.dp, shape, clip = false, ambientColor = glow, spotColor = glow) + .clip(shape) + .background(tint) + else Modifier.paddockCard(shape, elevation = if (compact) 4.dp else 10.dp) + ) + .heightIn(min = if (compact) 52.dp else 96.dp) + .padding(horizontal = if (compact) Spacing.lg else Spacing.xl, vertical = if (compact) Spacing.md else Spacing.lg), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = if (compact) Arrangement.Center else Arrangement.Start ) { - Column(modifier = Modifier.weight(1f)) { + if (icon != null) { + if (compact) { + Icon(icon, contentDescription = null, tint = if (primary) onTint else tint, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(Spacing.sm)) + } else { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(if (primary) onTint.copy(alpha = 0.14f) else tint.copy(alpha = 0.14f)), + contentAlignment = Alignment.Center + ) { + Icon(icon, contentDescription = null, tint = if (primary) onTint else tint, modifier = Modifier.size(24.dp)) + } + Spacer(Modifier.width(Spacing.lg)) + } + } + Column(modifier = if (compact) Modifier else Modifier.weight(1f)) { Text( - text = label.uppercase(), - // Compact controls take the title face, not the display face: a numeral - // face shrunk to button size is a display face pretending to be a label. - style = if (compact) TrackProType.titleLarge.atSize(15.sp) - else TrackProType.displayNumeric.atSize(labelSize), + text = label, + style = if (compact) TrackProType.titleMedium else TrackProType.titleLarge, color = ink, textAlign = if (compact) TextAlign.Center else TextAlign.Start, maxLines = 1, - modifier = if (compact) Modifier.fillMaxWidth() else Modifier + overflow = TextOverflow.Ellipsis ) - if (detail != null) { - Spacer(Modifier.height(3.dp)) - Text( - text = detail.uppercase(), - style = TrackProType.label, - color = TrackProTheme.colors.markingDim, - textAlign = if (compact) TextAlign.Center else TextAlign.Start, - maxLines = 1, - modifier = if (compact) Modifier.fillMaxWidth() else Modifier - ) + if (detail != null && !compact) { + Spacer(Modifier.height(2.dp)) + Text(text = detail, style = TrackProType.label, color = sub, maxLines = 2, overflow = TextOverflow.Ellipsis) } } if (!compact) { - Spacer(Modifier.width(12.dp)) - SegmentBar( - signedFraction = if (enabled) 1f else 0f, - activeColor = lit, - bidirectional = false, - segments = 4, - height = 26.dp, - modifier = Modifier.width(34.dp) + Spacer(Modifier.width(Spacing.sm)) + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = null, + tint = if (primary) onTint else TrackProTheme.colors.markingDim, + modifier = Modifier.size(20.dp) ) } } } /** - * A flat titled group. - * - * The dash has no cards, so a section is a placard on the panel with its contents on a - * field ground, closed top and bottom by bezels. Full bleed - a milled panel does not - * inset its apertures from the edge. + * A titled card section: the title in the page margin, the contents in one raised card. */ @Composable fun DashGroup( @@ -372,23 +468,7 @@ fun DashGroup( content: @Composable ColumnScope.() -> Unit ) { Column(modifier = modifier.fillMaxWidth()) { - Text( - text = title.uppercase(), - style = TrackProType.label, - color = TrackProTheme.colors.markingDim, - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.panel) - .padding(start = 14.dp, end = 14.dp, top = 16.dp, bottom = 6.dp) - ) - Bezel() - Column( - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.field) - .padding(horizontal = 14.dp, vertical = 12.dp), - content = content - ) - Bezel() + SectionTitle(title) + PaddockCard(modifier = Modifier.padding(horizontal = Spacing.gutter), content = content) } } diff --git a/app/src/main/java/com/example/trackpro/components/DashNavigation.kt b/app/src/main/java/com/example/trackpro/components/DashNavigation.kt index efe0694..822c64e 100644 --- a/app/src/main/java/com/example/trackpro/components/DashNavigation.kt +++ b/app/src/main/java/com/example/trackpro/components/DashNavigation.kt @@ -1,5 +1,7 @@ package com.example.trackpro.components +import androidx.compose.ui.res.stringResource +import com.example.trackpro.components.pluralResource import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background @@ -9,10 +11,10 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon import androidx.compose.material3.Text @@ -20,36 +22,35 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.theme.Spacing +import com.example.trackpro.theme.TrackProShapes import com.example.trackpro.theme.TrackProType -import com.example.trackpro.theme.bezel -import com.example.trackpro.theme.field +import com.example.trackpro.theme.accentSoft import com.example.trackpro.theme.fieldLive import com.example.trackpro.theme.marking import com.example.trackpro.theme.markingDim import com.example.trackpro.theme.panel -import com.example.trackpro.theme.segmentOff /** One destination on the [DashTabBar]. */ -data class DashTab(val route: String, val label: String, val icon: ImageVector) +data class DashTab(val route: String, @androidx.annotation.StringRes val label: Int, val icon: ImageVector) -/** Tall enough for a 48dp target plus the lit strip above it. */ -val DashTabBarHeight = 60.dp +/** Height of the floating bar itself, excluding its margin and the system inset. */ +val DashTabBarHeight = 68.dp /** - * The four tabs, drawn as the bottom row of switches on a dash. + * The tabs, as a floating rounded bar in the thumb zone. * - * Each tab is an aperture; the one you are on is lit — field-live ground, marking ink, and a - * strip of accent segments across its top edge, the same segment language the lit blocks and - * the delta bar speak. The others are unlit switches: present, readable, not shouting. - * No pill indicator and no ripple; the panel has neither. - * - * Hidden on the two HUDs: nothing on a driving surface may be one mis-tap from leaving it. + * The selected tab lights up in orange with a soft orange pill behind its icon; the rest stay + * quiet. Hidden on the two HUDs: nothing on a driving surface may be one mis-tap from + * leaving it. */ @Composable fun DashTabBar( @@ -58,16 +59,22 @@ fun DashTabBar( onSelect: (DashTab) -> Unit, modifier: Modifier = Modifier ) { - Column( + Box( modifier = modifier .fillMaxWidth() .background(TrackProTheme.colors.panel) .navigationBarsPadding() + .padding(start = Spacing.gutter, end = Spacing.gutter, top = Spacing.xs, bottom = Spacing.md) ) { - Bezel() - Row(modifier = Modifier.fillMaxWidth().height(DashTabBarHeight)) { - tabs.forEachIndexed { index, tab -> - if (index > 0) Bezel(vertical = true, modifier = Modifier.fillMaxHeight()) + Row( + modifier = Modifier + .fillMaxWidth() + .height(DashTabBarHeight) + .paddockCard(shape = TrackProShapes.card, elevation = 16.dp) + .padding(horizontal = Spacing.xs), + verticalAlignment = Alignment.CenterVertically + ) { + tabs.forEach { tab -> DashTabCell( tab = tab, isSelected = tab.route == selectedRoute, @@ -87,53 +94,48 @@ private fun DashTabCell( modifier: Modifier = Modifier ) { val ink by animateColorAsState( - if (isSelected) TrackProTheme.colors.marking else TrackProTheme.colors.markingDim, - animationSpec = tween(120), + if (isSelected) TrackProTheme.colors.accent else TrackProTheme.colors.markingDim, + animationSpec = tween(160), label = "tabInk" ) + val pill by animateColorAsState( + if (isSelected) TrackProTheme.colors.accentSoft else TrackProTheme.colors.accentSoft.copy(alpha = 0f), + animationSpec = tween(160), + label = "tabPill" + ) Column( modifier = modifier .fillMaxHeight() - .pressableRow(onClick = onClick, haptic = if (isSelected) null else Haptic.Selection, role = Role.Tab) - .semantics { selected = isSelected } - .background(if (isSelected) TrackProTheme.colors.fieldLive else TrackProTheme.colors.panel), - horizontalAlignment = Alignment.CenterHorizontally + .pressable(onClick = onClick, haptic = if (isSelected) null else Haptic.Selection, role = Role.Tab, scale = 0.94f) + .semantics { selected = isSelected }, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center ) { - // The lit strip. Unlit segments are drawn, never omitted, so every tab keeps its edge. - Row( - modifier = Modifier.fillMaxWidth().height(3.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - repeat(4) { - Box( - Modifier - .weight(1f) - .fillMaxHeight() - .background(if (isSelected) TrackProTheme.colors.accent else TrackProTheme.colors.segmentOff) - ) - } - } - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center + Box( + modifier = Modifier + .size(width = 56.dp, height = 30.dp) + .clip(TrackProShapes.pill) + .background(pill), + contentAlignment = Alignment.Center ) { - Icon(tab.icon, contentDescription = null, tint = ink, modifier = Modifier.size(20.dp)) - Spacer(Modifier.height(4.dp)) - Text(tab.label.uppercase(), style = TrackProType.label, color = ink, maxLines = 1) + Icon(tab.icon, contentDescription = null, tint = ink, modifier = Modifier.size(22.dp)) } + Spacer(Modifier.height(2.dp)) + Text( + stringResource(tab.label), + style = TrackProType.label.copy(fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal), + color = if (isSelected) TrackProTheme.colors.marking else TrackProTheme.colors.markingDim, + maxLines = 1 + ) } } -/** Height of a [SectionSwitch]: one 48dp target row and its closing bezel. */ -val SectionSwitchHeight = 49.dp +/** Height of a [SectionSwitch] including its margin. */ +val SectionSwitchHeight = 64.dp /** - * Splits one tab into two or three sections — Track | Drag, Cars | Tracks. - * - * Deliberately quieter than the tab bar above it: the selected section is lit field with a - * two-segment accent underline, not a filled block, so the eye still ranks "which tab" over - * "which half of it". Full bleed, like every aperture on the panel. + * Splits one tab into sections - Track | Drag, Cars | Tracks - as a segmented control: a + * rounded track with the selected segment raised on it. */ @Composable fun SectionSwitch( @@ -142,48 +144,53 @@ fun SectionSwitch( onSelect: (T) -> Unit, modifier: Modifier = Modifier ) { - Column(modifier = modifier.fillMaxWidth().background(TrackProTheme.colors.field)) { - Row(modifier = Modifier.fillMaxWidth().height(48.dp)) { - options.forEachIndexed { index, (value, label) -> + Box( + modifier = modifier + .fillMaxWidth() + .background(TrackProTheme.colors.panel) + .padding(horizontal = Spacing.gutter, vertical = Spacing.sm) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .paddockCard(shape = TrackProShapes.control, elevation = 0.dp) + .padding(4.dp) + ) { + options.forEach { (value, label) -> val isSelected = value == selected - if (index > 0) Bezel(vertical = true, modifier = Modifier.fillMaxHeight()) + val bg by animateColorAsState( + if (isSelected) TrackProTheme.colors.fieldLive else TrackProTheme.colors.fieldLive.copy(alpha = 0f), + animationSpec = tween(160), + label = "segment" + ) Box( modifier = Modifier .weight(1f) .fillMaxHeight() - .pressableRow( + .clip(TrackProShapes.chip) + .background(bg) + .pressable( onClick = { onSelect(value) }, haptic = if (isSelected) null else Haptic.Selection, - role = Role.Tab + role = Role.Tab, + scale = 0.97f ) - .semantics { this.selected = isSelected } - .background(if (isSelected) TrackProTheme.colors.fieldLive else TrackProTheme.colors.field), + .semantics { this.selected = isSelected }, contentAlignment = Alignment.Center ) { Text( - text = label.uppercase(), + text = label, style = TrackProType.titleMedium, color = if (isSelected) TrackProTheme.colors.marking else TrackProTheme.colors.markingDim ) - if (isSelected) { - Row( - modifier = Modifier - .align(Alignment.BottomCenter) - .size(width = 38.dp, height = 2.dp), - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - repeat(2) { - Box(Modifier.weight(1f).fillMaxHeight().background(TrackProTheme.colors.accent)) - } - } - } } } } - Box(Modifier.fillMaxWidth().height(1.dp).background(TrackProTheme.colors.bezel)) } } -/** "1 CAR", "7 TRACKS": a count as a placard, singular when there is one. */ -fun countLabel(count: Int, noun: String): String = - "$count ${if (count == 1) noun else noun + "s"}".uppercase() +/** "1 car", "7 tracks": a count with its noun, by the language's own plural rule. */ +@Composable +fun countLabel(count: Int, @androidx.annotation.PluralsRes noun: Int): String = + pluralResource(noun, count) diff --git a/app/src/main/java/com/example/trackpro/components/DataGate.kt b/app/src/main/java/com/example/trackpro/components/DataGate.kt index 3c06f93..adaee85 100644 --- a/app/src/main/java/com/example/trackpro/components/DataGate.kt +++ b/app/src/main/java/com/example/trackpro/components/DataGate.kt @@ -1,5 +1,7 @@ package com.example.trackpro.components +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -38,8 +40,10 @@ fun DataGate( emptyMessage: String, emptyHint: String, modifier: Modifier = Modifier, - loadingLabel: String = "Reading", + loadingLabel: String = stringResource(R.string.common_reading), onRetry: (() -> Unit)? = null, + emptyActionLabel: String? = null, + onEmptyAction: (() -> Unit)? = null, content: @Composable (List) -> Unit ) { when { @@ -52,7 +56,7 @@ fun DataGate( modifier = Modifier.padding(horizontal = 24.dp) ) { Text( - text = "READ FAILED", + text = stringResource(R.string.common_read_failed), style = TrackProType.titleMedium, color = TrackProTheme.colors.deltaBad ) @@ -66,7 +70,7 @@ fun DataGate( if (onRetry != null) { Spacer(Modifier.height(16.dp)) DashAction( - label = "Retry", + label = stringResource(R.string.common_retry), onClick = onRetry, compact = true, modifier = Modifier.width(160.dp) @@ -81,7 +85,7 @@ fun DataGate( ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - text = loadingLabel.uppercase(), + text = loadingLabel, style = TrackProType.label, color = TrackProTheme.colors.markingDim ) @@ -101,6 +105,8 @@ fun DataGate( items.isEmpty() -> EmptyState( message = emptyMessage, hint = emptyHint, + actionLabel = emptyActionLabel, + onAction = onEmptyAction, modifier = modifier.fillMaxWidth() ) diff --git a/app/src/main/java/com/example/trackpro/components/DataLabels.kt b/app/src/main/java/com/example/trackpro/components/DataLabels.kt new file mode 100644 index 0000000..98c3295 --- /dev/null +++ b/app/src/main/java/com/example/trackpro/components/DataLabels.kt @@ -0,0 +1,75 @@ +package com.example.trackpro.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.example.trackpro.R +import java.util.Locale + +/* + * Values that are stored in English - the car options from vehicle_options.json, a track's + * type and country - shown in the reader's language. Only the display changes: the stored + * value stays what it is, so syncing, filtering and older data are unaffected. A value with + * no translation (a custom entry, a code like RWD) is shown as stored. + */ + +private val specLabels: Map = mapOf( + "Petrol" to R.string.spec_petrol, + "Diesel" to R.string.spec_diesel, + "Electric" to R.string.spec_electric, + "Hybrid" to R.string.spec_hybrid, + "Ethanol" to R.string.spec_ethanol, + "Hydrogen" to R.string.spec_hydrogen, + "Methanol" to R.string.spec_methanol, + "Street" to R.string.spec_street, + "Semi-Slick" to R.string.spec_semi_slick, + "Slick" to R.string.spec_slick, + "All-Weather" to R.string.spec_all_weather, + "Soft Compound" to R.string.spec_soft_compound, + "Medium Compound" to R.string.spec_medium_compound, + "Hard Compound" to R.string.spec_hard_compound, + "Wet Tires" to R.string.spec_wet_tires, + "Drag Radials" to R.string.spec_drag_radials, + "Off-Road" to R.string.spec_off_road, + "Manual" to R.string.spec_manual, + "Automatic" to R.string.spec_automatic, + "Sequential" to R.string.spec_sequential, + "Dual-Clutch" to R.string.spec_dual_clutch, + "CVT" to R.string.spec_cvt, + "Direct-Drive" to R.string.spec_direct_drive, + "Independent" to R.string.spec_independent, + "Double Wishbone" to R.string.spec_double_wishbone, + "Pushrod" to R.string.spec_pushrod, + "Multilink" to R.string.spec_multilink, + "Trailing Arm" to R.string.spec_trailing_arm, + "Torsion Beam" to R.string.spec_torsion_beam, + "Active Suspension" to R.string.spec_active_suspension, + "Hydropneumatic" to R.string.spec_hydropneumatic, + "Solid Axle" to R.string.spec_solid_axle, + "Inline-3" to R.string.spec_inline_3, + "Inline-4" to R.string.spec_inline_4, + "Inline-5" to R.string.spec_inline_5, + "Inline-6" to R.string.spec_inline_6, + "Rotary-2" to R.string.spec_rotary_2, +) + +/** A car option (fuel, tyres, gearbox, suspension, engine) in the reader's language. */ +@Composable +fun specLabel(value: String): String = specLabels[value]?.let { stringResource(it) } ?: value + +/** Every known option, translated, for lists that cannot call [specLabel] per item. */ +@Composable +fun specLabelMap(): Map = specLabels.mapValues { (_, id) -> stringResource(id) } + +@Composable +fun trackTypeLabel(type: String): String = when (type) { + "Circuit" -> stringResource(R.string.track_type_circuit) + "Sprint" -> stringResource(R.string.track_type_sprint) + else -> type +} + +/** A country stored by its English name, in the current language: "Hungary" -> "Magyarország". */ +fun localizedCountry(name: String): String { + if (name.isBlank()) return name + val code = Locale.getISOCountries().firstOrNull { Locale("", it).getDisplayCountry(Locale.ENGLISH).equals(name, ignoreCase = true) } + return code?.let { Locale("", it).displayCountry } ?: name +} diff --git a/app/src/main/java/com/example/trackpro/components/DraggableSheet.kt b/app/src/main/java/com/example/trackpro/components/DraggableSheet.kt index 236c9bb..0d23527 100644 --- a/app/src/main/java/com/example/trackpro/components/DraggableSheet.kt +++ b/app/src/main/java/com/example/trackpro/components/DraggableSheet.kt @@ -184,7 +184,7 @@ fun DraggableSheet( .fillMaxWidth() .background( TrackProTheme.colors.bgCard, - RoundedCornerShape(0.dp) + RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp) ) ) { // The handle is the drag affordance, so it's also the drag *target* - a diff --git a/app/src/main/java/com/example/trackpro/components/EmptyState.kt b/app/src/main/java/com/example/trackpro/components/EmptyState.kt index 455ae43..0929a0e 100644 --- a/app/src/main/java/com/example/trackpro/components/EmptyState.kt +++ b/app/src/main/java/com/example/trackpro/components/EmptyState.kt @@ -1,44 +1,57 @@ package com.example.trackpro.components +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Inbox import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.theme.Spacing import com.example.trackpro.theme.TrackProType import com.example.trackpro.theme.marking import com.example.trackpro.theme.markingDim +/** + * An empty screen as an invitation: an icon, what belongs here, why, and the action that + * fills it. Never a bare "nothing here". + */ @Composable fun EmptyState( message: String, modifier: Modifier = Modifier, - hint: String? = null + hint: String? = null, + icon: ImageVector = Icons.Outlined.Inbox, + actionLabel: String? = null, + onAction: (() -> Unit)? = null, ) { - Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = message.uppercase(), - style = TrackProType.titleMedium, - // Was textFaint (3.35:1). An empty state is the only thing on the screen; - // it is the last place that should be the dimmest text in the app. - color = TrackProTheme.colors.marking - ) + Box(modifier = modifier.fillMaxSize().padding(Spacing.xl), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.widthIn(max = 320.dp) + ) { + IconCircle(icon = icon, size = 64.dp) + Spacer(Modifier.height(Spacing.lg)) + Text(text = message, style = TrackProType.titleLarge, color = TrackProTheme.colors.marking, textAlign = TextAlign.Center) if (hint != null) { - Spacer(Modifier.height(6.dp)) - Text( - text = hint, - style = TrackProType.body, - // Was textFaint at 70% alpha - composited to 2.21:1, the worst text - // contrast in the build, on first-run copy telling people what to do. - color = TrackProTheme.colors.markingDim - ) + Spacer(Modifier.height(Spacing.sm)) + Text(text = hint, style = TrackProType.body, color = TrackProTheme.colors.markingDim, textAlign = TextAlign.Center) + } + if (actionLabel != null && onAction != null) { + Spacer(Modifier.height(Spacing.xl)) + PrimaryButton(text = actionLabel, onClick = onAction) } } } diff --git a/app/src/main/java/com/example/trackpro/components/ExpandableGroup.kt b/app/src/main/java/com/example/trackpro/components/ExpandableGroup.kt index e53e9fb..13b1976 100644 --- a/app/src/main/java/com/example/trackpro/components/ExpandableGroup.kt +++ b/app/src/main/java/com/example/trackpro/components/ExpandableGroup.kt @@ -1,5 +1,7 @@ package com.example.trackpro.components +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Column @@ -11,7 +13,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.KeyboardArrowDown -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -25,15 +26,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import com.example.trackpro.extrasForUI.TrackProTheme import com.example.trackpro.theme.Motion -import com.example.trackpro.theme.markingDim import com.example.trackpro.theme.Spacing +import com.example.trackpro.theme.markingDim -/** - * Click-to-expand accordion shell shared by the drag-session and track-session list - * screens (previously two structurally-identical copies: ExpandableSessionGroup / - * ExpandableTrackGroup). The border tints towards [accent] while expanded, echoing the - * same "active state" treatment used on selection/toggle cards elsewhere. - */ +/** A card that opens to show its rows. Shared by the drag and track session lists. */ @Composable fun ExpandableGroup( modifier: Modifier = Modifier, @@ -43,50 +39,36 @@ fun ExpandableGroup( content: @Composable ColumnScope.() -> Unit ) { var expanded by remember { mutableStateOf(initiallyExpanded) } - // The chevron used to jump 180 degrees and the body used to pop in with no - // transition at all - the two most obviously "computer" moments in the app. val chevronRotation by animateFloatAsState( targetValue = if (expanded) 180f else 0f, animationSpec = Motion.standard(), label = "chevron" ) - AppCard( + Column( modifier = modifier .fillMaxWidth() - .animateContentSize(animationSpec = Motion.contentSize()), - padding = 0.dp, - borderColor = if (expanded) accent.copy(alpha = 0.5f) else TrackProTheme.colors.sectorLine + .paddockCard() + .animateContentSize(animationSpec = Motion.contentSize()) ) { Row( modifier = Modifier .fillMaxWidth() - // Row highlight rather than a scale: this header has no surface of its - // own (AppCard draws it), so a transform would shrink only the text. .pressableRow(onClick = { expanded = !expanded }) - .padding(Spacing.md), + .padding(horizontal = Spacing.lg, vertical = Spacing.md), verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier.weight(1f), - verticalAlignment = Alignment.CenterVertically - ) { - header() - } + Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) { header() } Icon( imageVector = Icons.Default.KeyboardArrowDown, - contentDescription = if (expanded) "Collapse" else "Expand", + contentDescription = if (expanded) stringResource(R.string.common_collapse) else stringResource(R.string.common_expand), tint = if (expanded) accent else TrackProTheme.colors.markingDim, - modifier = Modifier - .size(18.dp) - .rotate(chevronRotation) + modifier = Modifier.size(22.dp).rotate(chevronRotation) ) } if (expanded) { - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) - Column(modifier = Modifier.padding(Spacing.md)) { - content() - } + Bezel(modifier = Modifier.padding(horizontal = Spacing.lg)) + Column(modifier = Modifier.padding(horizontal = Spacing.sm, vertical = Spacing.sm)) { content() } } } } diff --git a/app/src/main/java/com/example/trackpro/components/PhotoFrame.kt b/app/src/main/java/com/example/trackpro/components/PhotoFrame.kt index 01051ac..449641d 100644 --- a/app/src/main/java/com/example/trackpro/components/PhotoFrame.kt +++ b/app/src/main/java/com/example/trackpro/components/PhotoFrame.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.produceState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale @@ -48,11 +49,12 @@ fun PhotoFrame( modifier: Modifier = Modifier, /** Shown instead of the car mark when there is no photo, e.g. a driver's initials. */ initials: String? = null, + shape: androidx.compose.ui.graphics.Shape = com.example.trackpro.theme.TrackProShapes.control, ) { BoxWithConstraints( modifier = modifier - .background(TrackProTheme.colors.field) - .border(1.dp, TrackProTheme.colors.bezel), + .clip(shape) + .background(TrackProTheme.colors.bgElevated), contentAlignment = Alignment.Center ) { val targetPx = with(LocalDensity.current) { maxOf(maxWidth, maxHeight).toPx().toInt() }.coerceAtLeast(1) @@ -71,8 +73,8 @@ fun PhotoFrame( } else if (initials != null) { Text( text = initials, - style = TrackProType.displayNumeric.atSize((maxHeight.value * 0.38f).coerceIn(14f, 40f).sp), - color = TrackProTheme.colors.markingDim + style = TrackProType.titleLarge.atSize((maxHeight.value * 0.36f).coerceIn(15f, 40f).sp), + color = TrackProTheme.colors.accent ) } else { Icon( diff --git a/app/src/main/java/com/example/trackpro/components/PrimaryButton.kt b/app/src/main/java/com/example/trackpro/components/PrimaryButton.kt index 11cd02e..c9ff48e 100644 --- a/app/src/main/java/com/example/trackpro/components/PrimaryButton.kt +++ b/app/src/main/java/com/example/trackpro/components/PrimaryButton.kt @@ -1,33 +1,32 @@ package com.example.trackpro.components import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.ui.semantics.Role import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import com.example.trackpro.extrasForUI.TrackProTheme -import com.example.trackpro.theme.atSize import com.example.trackpro.theme.Spacing import com.example.trackpro.theme.TrackProShapes -import com.example.trackpro.theme.bezel -import com.example.trackpro.theme.marking -import com.example.trackpro.theme.markingDim import com.example.trackpro.theme.TrackProType -import androidx.compose.foundation.layout.heightIn -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.selected +import com.example.trackpro.theme.accentGlow +import com.example.trackpro.theme.fieldLive +import com.example.trackpro.theme.marking /** - * Solid action button — the accent fills only this small control, never a whole bar. - * Pass [contentColor] to override the default onAccent foreground, e.g. for a - * secondary/neutral button (accent = bgElevated) where black text wouldn't read. + * The filled button: rounded, the accent with a soft glow of its own colour beneath it. + * One per screen. Pass [accent] and [contentColor] for a neutral or destructive variant. */ @Composable fun PrimaryButton( @@ -40,33 +39,28 @@ fun PrimaryButton( /** Only for genuine commits - starting a session, saving, marking a sector. */ haptic: Haptic? = null ) { + val isAccent = accent == TrackProTheme.colors.accent + val glow = if (isAccent) TrackProTheme.colors.accentGlow else Color.Transparent Box( modifier = modifier - // pressable first: the transform has to wrap the filled surface below it. - .pressable( - onClick = onClick, - enabled = enabled, - scale = 0.96f, - haptic = haptic, - role = Role.Button - ) - .background(if (enabled) accent else TrackProTheme.colors.bgElevated) - // A lit block with a milled edge, not a pill. Disabled keeps the block and - // drops to the bezel, so an unavailable action still shows where it is. - .border(1.dp, if (enabled) accent else TrackProTheme.colors.bezel) - .padding(horizontal = Spacing.lg, vertical = Spacing.md), + .alpha(if (enabled) 1f else 0.4f) + .pressable(onClick = onClick, enabled = enabled, scale = 0.96f, haptic = haptic, role = Role.Button) + .shadow(if (enabled && isAccent) 14.dp else 0.dp, TrackProShapes.control, clip = false, ambientColor = glow, spotColor = glow) + .clip(TrackProShapes.control) + .background(accent) + .heightIn(min = 52.dp) + .padding(horizontal = Spacing.xl, vertical = Spacing.md), contentAlignment = Alignment.Center ) { Text( - text = text.uppercase(), - style = TrackProType.titleLarge.atSize(14.sp), - color = contentColor - ?: if (enabled) TrackProTheme.colors.onAccent else TrackProTheme.colors.markingDim + text = text, + style = TrackProType.titleMedium, + color = contentColor ?: if (isAccent) TrackProTheme.colors.onAccent else TrackProTheme.colors.marking ) } } -/** Selectable pill used for mode/unit/sector-count pickers. */ +/** A selectable pill: mode, unit and count pickers. Selected fills with the accent. */ @Composable fun ToggleChip( text: String, @@ -75,42 +69,22 @@ fun ToggleChip( modifier: Modifier = Modifier, accent: Color = TrackProTheme.colors.accent ) { - // Aliased because inside the semantics block the bare name `selected` resolves to the - // property being assigned, not to this parameter. + // Aliased: inside the semantics block the bare name `selected` is the property. val isSelected = selected Box( modifier = modifier - // Selection is a discrete commit, so this is one of the few taps that earns - // a haptic - it fires on the same frame the fill flips. - .pressable( - onClick = onClick, - scale = 0.96f, - haptic = Haptic.Selection, - role = Role.RadioButton - ) - // Role.RadioButton alone announces "radio button" without saying which one is - // chosen; the fill was the only thing carrying that, and a screen reader cannot - // see a fill. + .pressable(onClick = onClick, scale = 0.96f, haptic = Haptic.Selection, role = Role.RadioButton) .semantics { this.selected = isSelected } - // 13sp over 8dp of padding measured about 32dp. This is the sector-count - // picker and both Settings toggles, so it is worth the extra height. .heightIn(min = 48.dp) - .background( - if (selected) accent else TrackProTheme.colors.bgElevated, - TrackProShapes.control - ) - .border( - width = 1.dp, - color = if (selected) Color.Transparent else TrackProTheme.colors.sectorLine, - shape = TrackProShapes.control - ) - .padding(horizontal = Spacing.md, vertical = Spacing.sm), + .clip(TrackProShapes.chip) + .background(if (selected) accent else TrackProTheme.colors.fieldLive) + .padding(horizontal = Spacing.lg, vertical = Spacing.sm), contentAlignment = Alignment.Center ) { Text( text = text, - style = TrackProType.titleMedium.atSize(13.sp), - color = if (selected) TrackProTheme.colors.onAccent else TrackProTheme.colors.textPrimary + style = TrackProType.titleMedium, + color = if (selected) TrackProTheme.colors.onAccent else TrackProTheme.colors.marking ) } } diff --git a/app/src/main/java/com/example/trackpro/components/ScreenScaffold.kt b/app/src/main/java/com/example/trackpro/components/ScreenScaffold.kt index 282240c..c0a9159 100644 --- a/app/src/main/java/com/example/trackpro/components/ScreenScaffold.kt +++ b/app/src/main/java/com/example/trackpro/components/ScreenScaffold.kt @@ -19,21 +19,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.theme.panel /** - * Standard screen shell: content runs full-bleed beneath an opaque chrome strip. - * - * **On the material.** The chrome is opaque. Translucency belonged to the previous visual - * direction; a milled panel does not show what is behind it, and a half-transparent bar - * over live telemetry costs legibility for nothing. This doc used to describe a floating - * translucent scrim long after the alpha had been set to 1 - if the material changes - * again, change both. - * - * **On the edge.** There is no permanent divider. The bezel appears only once content is - * actually scrolled underneath, so a short screen carries no line at all and a scrolled - * one gets the same 1dp hairline that separates every other field on the panel. It was a - * vertical gradient until the world went hard-edged; a gradient is the one thing the - * segment language explicitly refuses. + * Standard screen shell: the header floats over content on the page ground, content scrolls + * beneath it inset by the header's height, and a hairline appears under the header only + * once something has actually scrolled under it. */ @Composable fun ScreenScaffold( @@ -46,8 +37,8 @@ fun ScreenScaffold( contentScrolled: Boolean = false, bottomBar: (@Composable () -> Unit)? = null, /** - * A fixed strip under the bar, e.g. the [SectionSwitch] that splits a tab in two. It is - * part of the chrome, so content is inset by [headerHeight] as well as the bar. + * A fixed strip under the header, e.g. the [SectionSwitch] that splits a tab in two. + * Content is inset by [headerHeight] as well as the header. */ header: (@Composable () -> Unit)? = null, headerHeight: Dp = if (header != null) SectionSwitchHeight else 0.dp, @@ -56,21 +47,12 @@ fun ScreenScaffold( Box( modifier = modifier .fillMaxSize() - .background(TrackProTheme.colors.bgDeep) + .background(TrackProTheme.colors.panel) ) { content(PaddingValues(top = AppTopBarHeight + headerHeight)) - Column(modifier = Modifier.align(Alignment.TopStart)) { - AppTopBar( - title = title, - accent = accent, - subtitle = subtitle, - onBack = onBack, - trailing = trailing, - // The scaffold owns the material and the edge treatment. - containerColor = TrackProTheme.colors.bgCard.copy(alpha = TranslucentChromeAlpha), - showDivider = false - ) + Column(modifier = Modifier.align(Alignment.TopStart).background(TrackProTheme.colors.panel)) { + AppTopBar(title = title, accent = accent, subtitle = subtitle, onBack = onBack, trailing = trailing) if (header != null) { Box(modifier = Modifier.fillMaxWidth().height(headerHeight)) { header() } } @@ -82,7 +64,7 @@ fun ScreenScaffold( modifier = Modifier .align(Alignment.BottomStart) .fillMaxWidth() - .background(TrackProTheme.colors.bgCard.copy(alpha = TranslucentChromeAlpha)) + .background(TrackProTheme.colors.panel) ) { bottomBar() } @@ -90,29 +72,20 @@ fun ScreenScaffold( } } -/** - * Content dissolving under floating chrome, instead of being cut off by a rule. - * Renders nothing at all when there's no overlap to soften. - */ +/** A hairline under the header, drawn only once content has scrolled beneath it. */ @Composable fun ScrollEdgeFade(visible: Boolean) { if (!visible) return Bezel() } -/** - * Kept at 1 and kept named so the call sites read unchanged. The chrome is opaque; see - * the composable's note on the material. - */ -private const val TranslucentChromeAlpha = 1f - -/** True once the list has moved at all - drives the scroll-edge bezel. */ +/** True once the list has moved at all. */ @Composable fun LazyListState.isScrolledUnderChrome(): State = remember(this) { derivedStateOf { firstVisibleItemIndex > 0 || firstVisibleItemScrollOffset > 0 } } -/** True once the scroll container has moved at all - drives the scroll-edge fade. */ +/** True once the scroll container has moved at all. */ @Composable fun ScrollState.isScrolledUnderChrome(): State = remember(this) { derivedStateOf { value > 0 } diff --git a/app/src/main/java/com/example/trackpro/components/SectionLabel.kt b/app/src/main/java/com/example/trackpro/components/SectionLabel.kt index 421b48a..0284563 100644 --- a/app/src/main/java/com/example/trackpro/components/SectionLabel.kt +++ b/app/src/main/java/com/example/trackpro/components/SectionLabel.kt @@ -1,46 +1,19 @@ package com.example.trackpro.components -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp import com.example.trackpro.extrasForUI.TrackProTheme import com.example.trackpro.theme.TrackProType -import com.example.trackpro.theme.markingDim +import com.example.trackpro.theme.marking -/** - * A section placard. - * - * On a panel a section is announced by a struck mark and a caption, not by a larger - * heading - the type scale is reserved for readouts. The short accent tick is that mark: - * it gives the caption something to hang from without borrowing weight from the values - * underneath it. - */ +/** A section heading inside a screen. Sentence case, as written. */ @Composable fun SectionLabel( text: String, modifier: Modifier = Modifier, - color: Color = TrackProTheme.colors.markingDim + color: Color = TrackProTheme.colors.marking ) { - Column(modifier = modifier) { - Box( - modifier = Modifier - .width(18.dp) - .height(2.dp) - .background(TrackProTheme.colors.accent) - ) - Spacer(Modifier.height(6.dp)) - Text( - text = text.uppercase(), - style = TrackProType.label, - color = color - ) - } + Text(text = text, style = TrackProType.titleMedium, color = color, modifier = modifier) } diff --git a/app/src/main/java/com/example/trackpro/components/SessionRow.kt b/app/src/main/java/com/example/trackpro/components/SessionRow.kt new file mode 100644 index 0000000..5ef6616 --- /dev/null +++ b/app/src/main/java/com/example/trackpro/components/SessionRow.kt @@ -0,0 +1,88 @@ +package com.example.trackpro.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.theme.Spacing +import com.example.trackpro.theme.TrackProShapes +import com.example.trackpro.theme.TrackProType +import com.example.trackpro.theme.marking +import com.example.trackpro.theme.markingDim +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * One session in a history list: a date badge in place of a bullet, what the session was, + * when, and where tapping leads. Voided sessions carry their mark instead of the chevron. + * Hold to delete. + */ +@Composable +fun SessionRow( + startTime: Long, + title: String, + subtitle: String, + voided: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit, +) { + val zoned = Instant.ofEpochMilli(startTime).atZone(ZoneId.systemDefault()) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(TrackProShapes.control) + .pressable(onClick = onClick, onLongClick = onLongClick, scale = 0.98f) + .heightIn(min = 64.dp) + .padding(horizontal = Spacing.sm, vertical = Spacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Column( + modifier = Modifier + .size(48.dp) + .clip(TrackProShapes.chip) + .background(TrackProTheme.colors.bgElevated), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center + ) { + Text( + zoned.dayOfMonth.toString(), + style = TrackProType.titleMedium, + color = TrackProTheme.colors.marking + ) + Text( + zoned.format(DateTimeFormatter.ofPattern("MMM", Locale.getDefault())), + style = TrackProType.label.copy(fontWeight = FontWeight.SemiBold), + color = TrackProTheme.colors.accent + ) + } + Spacer(Modifier.width(Spacing.md)) + Column(Modifier.weight(1f)) { + Text(title, style = TrackProType.titleMedium, color = TrackProTheme.colors.marking, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(subtitle, style = TrackProType.label, color = TrackProTheme.colors.markingDim, maxLines = 1) + } + if (voided) { + VoidStamp() + } else { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = TrackProTheme.colors.markingDim) + } + } +} diff --git a/app/src/main/java/com/example/trackpro/components/SessionSummary.kt b/app/src/main/java/com/example/trackpro/components/SessionSummary.kt index 4571096..1b0e8f0 100644 --- a/app/src/main/java/com/example/trackpro/components/SessionSummary.kt +++ b/app/src/main/java/com/example/trackpro/components/SessionSummary.kt @@ -1,5 +1,11 @@ package com.example.trackpro.components +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -10,31 +16,62 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.theme.Spacing +import com.example.trackpro.theme.TrackProShapes import com.example.trackpro.theme.TrackProType +import com.example.trackpro.theme.accentGlow import com.example.trackpro.theme.atSize -import com.example.trackpro.theme.field import com.example.trackpro.theme.marking import com.example.trackpro.theme.markingDim import com.example.trackpro.theme.panel +import java.util.Locale /** - * What the driver sees in the five seconds after a stint. + * A session that beat the driver's previous best on this track, or set the first time there. + * [previousMs] is null for a first time on the track. + */ +data class PersonalBestMoment(val newMs: Long, val previousMs: Long?) + +/** + * What the driver sees after a stint: the end of the experience, so it closes with a + * summary rather than a silent save. * - * Previously: nothing. Back was tapped, the session saved silently in `onCleared()`, and - * the panel reappeared showing dashes until an async query landed. That is the emotional - * peak of the whole product and the one moment the app had the driver's full attention, - * and it said nothing at all. + * When the session set a personal best this is also the peak: a trophy that springs in with + * a soft orange glow and says by how much. Nothing moves while driving - the celebration + * waits for this screen, where there is attention to spare. * - * This is also the only correct home for VOID. A discard decision is made here, once, - * while the run is still in mind - not later from a list where every session looks the - * same. Keep is the default and the larger target; voiding is deliberate. + * It is the one place a run is voided, decided while the run is still in mind. Keep is the + * default and the larger target. */ @Composable fun SessionSummary( @@ -44,85 +81,167 @@ fun SessionSummary( onKeep: () -> Unit, onVoid: () -> Unit, modifier: Modifier = Modifier, - saveFailed: String? = null + saveFailed: String? = null, + personalBest: PersonalBestMoment? = null, ) { Column( modifier = modifier .fillMaxSize() .background(TrackProTheme.colors.panel) + .statusBarsPadding() + .padding(horizontal = Spacing.gutter) ) { - Spacer(Modifier.weight(1f)) + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(Spacing.md) + ) { + Spacer(Modifier.height(Spacing.xl)) - Column(modifier = Modifier.padding(horizontal = 16.dp)) { - Text( - text = if (saveFailed != null) "SESSION NOT SAVED" else "SESSION COMPLETE", - style = TrackProType.label, - color = if (saveFailed != null) TrackProTheme.colors.danger - else TrackProTheme.colors.deltaGood - ) - Spacer(Modifier.height(12.dp)) - Readout( - value = headline, - caption = headlineCaption, - valueColor = TrackProTheme.colors.accent, - valueSize = 64.sp - ) - } + when { + saveFailed != null -> StatusHeader(Icons.Default.ErrorOutline, stringResource(R.string.summary_not_saved), TrackProTheme.colors.danger) + personalBest != null -> PersonalBestCelebration(personalBest) + else -> StatusHeader(Icons.Default.CheckCircle, stringResource(R.string.summary_complete), TrackProTheme.colors.deltaGood) + } - Spacer(Modifier.height(20.dp)) - Bezel() + PaddockCard { + Text(headlineCaption, style = TrackProType.label, color = TrackProTheme.colors.markingDim) + Text( + headline, + style = TrackProType.displayNumeric.atSize(56.sp), + color = if (personalBest != null) TrackProTheme.colors.accent else TrackProTheme.colors.marking + ) + } - rows.forEachIndexed { i, (label, value) -> - Row(modifier = Modifier.fillMaxWidth()) { - Instrument(label = label, value = value, valueSize = 20.sp, modifier = Modifier.weight(1f)) + if (rows.isNotEmpty()) { + PaddockCard(padding = 0.dp) { + rows.forEachIndexed { i, (label, value) -> + if (i > 0) Bezel(Modifier.padding(horizontal = Spacing.lg)) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.lg, vertical = Spacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + Text(label, style = TrackProType.body, color = TrackProTheme.colors.markingDim, modifier = Modifier.weight(1f)) + Text(value, style = TrackProType.titleMedium, color = TrackProTheme.colors.marking) + } + } + } } - if (i < rows.lastIndex) Bezel() - } - Bezel() - if (saveFailed != null) { - Box( - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.field) - .padding(horizontal = 16.dp, vertical = 12.dp) - ) { + if (saveFailed != null) { Text( text = saveFailed, - style = TrackProType.body.atSize(12.sp), - color = TrackProTheme.colors.danger + style = TrackProType.label, + color = TrackProTheme.colors.marking, + modifier = Modifier + .fillMaxWidth() + .clip(TrackProShapes.control) + .background(TrackProTheme.colors.danger.copy(alpha = 0.14f)) + .padding(Spacing.lg) ) } - Bezel() } - Spacer(Modifier.weight(1f)) - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.lg), + verticalArrangement = Arrangement.spacedBy(Spacing.md) ) { - DashAction( - label = "Keep", - detail = "Save to the archive", + PrimaryButton( + text = stringResource(R.string.summary_keep), onClick = onKeep, - haptic = Haptic.Confirm + haptic = Haptic.Confirm, + modifier = Modifier.fillMaxWidth() ) DashAction( - label = "Void this run", + label = stringResource(R.string.summary_void), onClick = onVoid, compact = true, accent = TrackProTheme.colors.danger, + icon = Icons.Default.Block, haptic = Haptic.Reject ) Text( - text = "A voided run is kept but stamped, and stops counting toward your bests.", + text = stringResource(R.string.summary_void_hint), style = TrackProType.label, color = TrackProTheme.colors.markingDim, - modifier = Modifier.align(Alignment.CenterHorizontally) + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Composable +private fun StatusHeader(icon: ImageVector, text: String, tone: Color) { + Row( + modifier = Modifier + .clip(TrackProShapes.pill) + .background(tone.copy(alpha = 0.14f)) + .padding(horizontal = Spacing.md, vertical = Spacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(icon, contentDescription = null, tint = tone, modifier = Modifier.size(18.dp)) + Spacer(Modifier.size(Spacing.sm)) + Text(text, style = TrackProType.label.copy(fontWeight = FontWeight.SemiBold), color = tone) + } +} + +/** + * The peak: a trophy springing in on a soft glow, the words stringResource(R.string.summary_new_pb), and the + * margin. Proportional - a spring and a glow, no confetti - because it is a timing app. + */ +@Composable +private fun PersonalBestCelebration(moment: PersonalBestMoment) { + val reducedMotion = rememberReducedMotion() + var shown by remember { mutableStateOf(reducedMotion) } + LaunchedEffect(Unit) { shown = true } + + val scale by animateFloatAsState( + targetValue = if (shown) 1f else 0.4f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow), + label = "trophyScale" + ) + val fade by animateFloatAsState( + targetValue = if (shown) 1f else 0f, + animationSpec = tween(360), + label = "trophyFade" + ) + val glow = TrackProTheme.colors.accentGlow + + PaddockCard { + Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Spacer(Modifier.height(Spacing.sm)) + Box( + modifier = Modifier + .graphicsLayer { scaleX = scale; scaleY = scale; alpha = fade } + .shadow(28.dp, CircleShape, clip = false, ambientColor = glow, spotColor = glow) + .size(88.dp) + .clip(CircleShape) + .background(TrackProTheme.colors.accent), + contentAlignment = Alignment.Center + ) { + Icon(Icons.Default.EmojiEvents, contentDescription = null, tint = TrackProTheme.colors.onAccent, modifier = Modifier.size(44.dp)) + } + Spacer(Modifier.height(Spacing.lg)) + Text( + if (moment.previousMs == null) stringResource(R.string.summary_first_on_board) else stringResource(R.string.summary_new_pb), + style = TrackProType.titleLarge, + color = TrackProTheme.colors.marking, + modifier = Modifier.graphicsLayer { alpha = fade } + ) + Spacer(Modifier.height(Spacing.xs)) + Text( + text = moment.previousMs?.let { prev -> + val gain = (prev - moment.newMs) / 1000.0 + stringResource(R.string.summary_faster_than_prev, String.format(Locale.US, "%.2f", gain)) + } ?: stringResource(R.string.summary_first_lap), + style = TrackProType.body, + color = TrackProTheme.colors.markingDim, + textAlign = TextAlign.Center, + modifier = Modifier.graphicsLayer { alpha = fade } ) + Spacer(Modifier.height(Spacing.sm)) } } } diff --git a/app/src/main/java/com/example/trackpro/components/StatCell.kt b/app/src/main/java/com/example/trackpro/components/StatCell.kt index 0327bee..c67cab7 100644 --- a/app/src/main/java/com/example/trackpro/components/StatCell.kt +++ b/app/src/main/java/com/example/trackpro/components/StatCell.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width @@ -16,19 +17,15 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.trackpro.extrasForUI.TrackProTheme -import com.example.trackpro.theme.atSize import com.example.trackpro.theme.TrackProType +import com.example.trackpro.theme.atSize import com.example.trackpro.theme.bezel import com.example.trackpro.theme.marking import com.example.trackpro.theme.markingDim enum class StatCellSize { Small, Regular, Large } -/** - * The "muted label over a bold value" readout used everywhere (power/torque/weight, - * GPS signal rows, lap stats, session metrics). One component instead of the ~9 - * near-identical ones that used to exist per-screen. - */ +/** A caption over a value, with an optional unit. The value is always the louder of the two. */ @Composable fun StatCell( label: String, @@ -39,39 +36,29 @@ fun StatCell( size: StatCellSize = StatCellSize.Regular, horizontalAlignment: Alignment.Horizontal = Alignment.Start ) { - // Placard caps sized to stay legible rather than to disappear: at 8sp the old Small - // tier was decoration. The value keeps a clear step above it at every tier so a cell - // always has a hierarchy of two, never one grey block. - val (labelSize, valueSize) = when (size) { - StatCellSize.Small -> 9.sp to 15.sp - StatCellSize.Regular -> 10.sp to 18.sp - StatCellSize.Large -> 10.sp to 24.sp + val valueSize = when (size) { + StatCellSize.Small -> 15.sp + StatCellSize.Regular -> 20.sp + StatCellSize.Large -> 20.sp } Column(horizontalAlignment = horizontalAlignment, modifier = modifier) { - Text( - text = label.uppercase(), - style = TrackProType.label.atSize(labelSize), - color = TrackProTheme.colors.markingDim - ) + Text(text = label, style = TrackProType.label, color = TrackProTheme.colors.markingDim, maxLines = 1) + Spacer(Modifier.height(2.dp)) Row(verticalAlignment = Alignment.Bottom) { - Text( - text = value, - style = TrackProType.statValue.atSize(valueSize), - color = valueColor - ) + Text(text = value, style = TrackProType.statValue.atSize(valueSize), color = valueColor, maxLines = 1) if (unit != null) { Text( text = unit, - style = TrackProType.body.atSize(10.sp), + style = TrackProType.label, color = TrackProTheme.colors.markingDim, - modifier = Modifier.padding(start = 2.dp, bottom = 1.dp) + modifier = Modifier.padding(start = 3.dp, bottom = 3.dp) ) } } } } -/** A 1dp vertical hairline for separating StatCells laid out in a Row. */ +/** A short vertical hairline between StatCells in a Row. */ @Composable fun StatCellDivider(modifier: Modifier = Modifier, height: Dp = 28.dp) { Box( diff --git a/app/src/main/java/com/example/trackpro/components/Strings.kt b/app/src/main/java/com/example/trackpro/components/Strings.kt new file mode 100644 index 0000000..80d0d2f --- /dev/null +++ b/app/src/main/java/com/example/trackpro/components/Strings.kt @@ -0,0 +1,21 @@ +package com.example.trackpro.components + +import androidx.annotation.PluralsRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext + +/** + * A quantity string from resources. English says "1 lap" and "3 laps"; Hungarian keeps the + * noun singular after a number ("3 kör"), and the plurals resource carries both rules. + * The count is passed as the first format argument unless others are given. + */ +@Composable +@ReadOnlyComposable +fun pluralResource(@PluralsRes id: Int, count: Int, vararg args: Any): String { + LocalConfiguration.current + val resources = LocalContext.current.resources + return if (args.isEmpty()) resources.getQuantityString(id, count, count) + else resources.getQuantityString(id, count, *args) +} diff --git a/app/src/main/java/com/example/trackpro/components/VoidStamp.kt b/app/src/main/java/com/example/trackpro/components/VoidStamp.kt index f32c9e3..0915a4d 100644 --- a/app/src/main/java/com/example/trackpro/components/VoidStamp.kt +++ b/app/src/main/java/com/example/trackpro/components/VoidStamp.kt @@ -1,33 +1,32 @@ package com.example.trackpro.components -import androidx.compose.foundation.border +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.padding import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.theme.TrackProShapes import com.example.trackpro.theme.TrackProType -/** - * The mark on a discarded run. - * - * A stamp, not a deletion and not a strikethrough: the session is still there, still - * openable, still carrying its trace. It simply stops counting. Outlined rather than - * filled so it reads as something applied on top of the row rather than as the row's own - * state colour, which is how a stamp behaves on paper and on a scrutineering sticker. - */ +/** Marks a voided session: kept, but out of every best. A soft red pill. */ @Composable fun VoidStamp(modifier: Modifier = Modifier) { Box( modifier = modifier - .border(1.dp, TrackProTheme.colors.danger) - .padding(horizontal = 5.dp, vertical = 1.dp) + .clip(TrackProShapes.pill) + .background(TrackProTheme.colors.danger.copy(alpha = 0.16f)) + .padding(horizontal = 8.dp, vertical = 2.dp) ) { Text( - text = "VOID", - style = TrackProType.label, + text = stringResource(R.string.void_stamp), + style = TrackProType.label.copy(fontWeight = FontWeight.SemiBold), color = TrackProTheme.colors.danger ) } diff --git a/app/src/main/java/com/example/trackpro/dao/LapTimeDataDAO.kt b/app/src/main/java/com/example/trackpro/dao/LapTimeDataDAO.kt index ef58062..e1dcd45 100644 --- a/app/src/main/java/com/example/trackpro/dao/LapTimeDataDAO.kt +++ b/app/src/main/java/com/example/trackpro/dao/LapTimeDataDAO.kt @@ -6,6 +6,7 @@ import androidx.room.Insert import androidx.room.Query import androidx.room.Update import com.example.trackpro.dataClasses.LapTimeData +import com.example.trackpro.models.SessionBestLap import kotlinx.coroutines.flow.Flow @Dao @@ -70,6 +71,17 @@ interface LapTimeDataDAO { """) suspend fun getBestLapForTrack(trackId: Long): LapTimeData? + // Every session's fastest completed lap, for listing and ordering sessions by pace. MIN + // over the stored "MM:SS.hh" text is MIN over time because the format is fixed-width; + // the unfinished and invalid markers are excluded, as they are wherever laps are ranked. + @Query(""" + SELECT sessionid AS sessionId, MIN(laptime) AS bestLap FROM lap_time_data + WHERE laptime != 'IN PROGRESS' + AND laptime != 'INVALID' + GROUP BY sessionid + """) + fun getBestLapPerSession(): Flow> + // The fastest laps on a track that are fit to be a live-delta reference, fastest first: // completed, and with no GPS gap - a gap is a straight chord through the lap's trace, // which would skew every delta measured against it. Several rather than one, because @@ -86,6 +98,22 @@ interface LapTimeDataDAO { """) suspend fun getFastestCleanLapsForTrack(trackId: Long, limit: Int): List + /** + * Every clean lap time on a track from sessions that count: not voided, no GPS gap, and + * actually finished. Parsed by the caller, which is why it returns the raw strings. + * Read once when a session starts, it is the personal best the session has to beat. + */ + @Query(""" + SELECT lap_time_data.laptime FROM lap_time_data + INNER JOIN session_data ON lap_time_data.sessionid = session_data.id + WHERE session_data.trackId = :trackId + AND session_data.voided = 0 + AND lap_time_data.signalGap = 0 + AND lap_time_data.laptime != 'IN PROGRESS' + AND lap_time_data.laptime != 'INVALID' + """) + suspend fun getCountedLapTimesForTrack(trackId: Long): List + // Get total number of laps in a session @Query("SELECT COUNT(*) FROM lap_time_data WHERE sessionid = :sessionId") suspend fun getLapCountForSession(sessionId: Long): Int diff --git a/app/src/main/java/com/example/trackpro/dao/SyncDao.kt b/app/src/main/java/com/example/trackpro/dao/SyncDao.kt index 9cfc1b7..fbc7382 100644 --- a/app/src/main/java/com/example/trackpro/dao/SyncDao.kt +++ b/app/src/main/java/com/example/trackpro/dao/SyncDao.kt @@ -73,6 +73,10 @@ interface SyncDao { @Query("SELECT * FROM session_data WHERE id = :id") suspend fun getSession(id: Long): SessionData? + /** Sessions still recording. Sync never withdraws one of these: a live event upload may be in flight. */ + @Query("SELECT id FROM session_data WHERE endTime IS NULL") + suspend fun getRunningSessionIds(): List + @Query("SELECT * FROM lap_time_data WHERE sessionid = :sessionId ORDER BY lapnumber ASC") suspend fun getLaps(sessionId: Long): List @@ -100,6 +104,45 @@ interface SyncDao { @Update suspend fun updateVehicle(vehicle: VehicleInformationData) + // ── Restore writes ── + // Plain inserts: the restore wraps each record's rows in one transaction itself, so a + // session is never left on the phone without its laps. + + @Query("SELECT * FROM track_main_data ORDER BY trackId") + suspend fun getTracks(): List + + @Insert + suspend fun insertTrack(track: TrackMainData): Long + + /** In list order, which becomes row id order - the order the timing code reads. */ + @Insert + suspend fun insertTrackPoints(points: List) + + @Insert + suspend fun insertSession(session: SessionData): Long + + @Insert + suspend fun insertLap(lap: LapTimeData): Long + + @Insert + suspend fun insertSectors(sectors: List) + + /** Whether any lap of the session has recorded GPS points. A restored session never does. */ + @Query( + """ + SELECT EXISTS(SELECT 1 FROM lap_info_data + WHERE lapid IN (SELECT id FROM lap_time_data WHERE sessionid = :sessionId)) + """ + ) + suspend fun hasGpsTrace(sessionId: Long): Boolean + + @Query("UPDATE session_data SET vehicleId = :vehicleId WHERE id = :sessionId") + suspend fun setSessionVehicle(sessionId: Long, vehicleId: Long) + + /** Laps and sectors go with it, by foreign-key cascade. */ + @Query("DELETE FROM session_data WHERE id = :sessionId") + suspend fun deleteSession(sessionId: Long) + /** Forgets every server link. Used when the account they point into is deleted. */ @Query("DELETE FROM remote_link") suspend fun deleteAllLinks() diff --git a/app/src/main/java/com/example/trackpro/dataClasses/RemoteLink.kt b/app/src/main/java/com/example/trackpro/dataClasses/RemoteLink.kt index fcd06e0..5f223e6 100644 --- a/app/src/main/java/com/example/trackpro/dataClasses/RemoteLink.kt +++ b/app/src/main/java/com/example/trackpro/dataClasses/RemoteLink.kt @@ -39,6 +39,11 @@ data class RemoteLink( * deleting the local copy can never delete the shared track other drivers' laps are on. */ const val KIND_PREMADE_TRACK = "premade_track" + /** + * Someone else's published track, downloaded to drive an event on it. Like + * [KIND_PREMADE_TRACK], it is never uploaded or deleted on the server from here. + */ + const val KIND_SHARED_TRACK = "shared_track" const val KIND_SESSION = "session" /** * A car's photo. [remoteId] holds the photo's public URL on the account, so a change diff --git a/app/src/main/java/com/example/trackpro/extrasForUI/DropDownMenuField.kt b/app/src/main/java/com/example/trackpro/extrasForUI/DropDownMenuField.kt index 82b2167..fcfbbf7 100644 --- a/app/src/main/java/com/example/trackpro/extrasForUI/DropDownMenuField.kt +++ b/app/src/main/java/com/example/trackpro/extrasForUI/DropDownMenuField.kt @@ -1,5 +1,7 @@ package com.example.trackpro.extrasForUI +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.fillMaxWidth @@ -8,9 +10,13 @@ import androidx.compose.material.ExposedDropdownMenuBox import androidx.compose.material.ExposedDropdownMenuDefaults import androidx.compose.material.Text import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.ui.draw.rotate +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.Icons import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -37,7 +43,7 @@ fun AppDropdownField( onSelect: (T) -> Unit, modifier: Modifier = Modifier, accent: Color = TrackProTheme.colors.accent, - emptyMessage: String = "No options available" + emptyMessage: String = stringResource(R.string.dropdown_no_options) ) { var expanded by remember { mutableStateOf(false) } @@ -51,12 +57,21 @@ fun AppDropdownField( onValueChange = {}, readOnly = true, label = { Text(label, color = TrackProTheme.colors.textMuted) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + trailingIcon = { + Icon( + Icons.Filled.ArrowDropDown, + contentDescription = null, + tint = if (expanded) TrackProTheme.colors.accent else TrackProTheme.colors.textMuted, + modifier = Modifier.rotate(if (expanded) 180f else 0f) + ) + }, colors = OutlinedTextFieldDefaults.colors( focusedTextColor = TrackProTheme.colors.textPrimary, unfocusedTextColor = TrackProTheme.colors.textPrimary, focusedBorderColor = accent, - unfocusedBorderColor = TrackProTheme.colors.sectorLine + unfocusedBorderColor = TrackProTheme.colors.sectorLine, + focusedTrailingIconColor = TrackProTheme.colors.accent, + unfocusedTrailingIconColor = TrackProTheme.colors.textMuted ), modifier = Modifier.fillMaxWidth() ) @@ -99,12 +114,21 @@ fun DropdownMenuFieldMulti(label: String, options: List, selectedOp onValueChange = {}, readOnly = true, label = { Text(label, color = TrackProTheme.colors.textMuted) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + trailingIcon = { + Icon( + Icons.Filled.ArrowDropDown, + contentDescription = null, + tint = if (expanded) TrackProTheme.colors.accent else TrackProTheme.colors.textMuted, + modifier = Modifier.rotate(if (expanded) 180f else 0f) + ) + }, colors = OutlinedTextFieldDefaults.colors( focusedTextColor = TrackProTheme.colors.textPrimary, unfocusedTextColor = TrackProTheme.colors.textPrimary, focusedBorderColor = TrackProTheme.colors.accent, - unfocusedBorderColor = TrackProTheme.colors.sectorLine + unfocusedBorderColor = TrackProTheme.colors.sectorLine, + focusedTrailingIconColor = TrackProTheme.colors.accent, + unfocusedTrailingIconColor = TrackProTheme.colors.textMuted ), modifier = Modifier .fillMaxWidth() @@ -147,12 +171,21 @@ fun TrackDropdownMenu( onValueChange = {}, readOnly = true, label = { Text(label, color = TrackProTheme.colors.textMuted) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, + trailingIcon = { + Icon( + Icons.Filled.ArrowDropDown, + contentDescription = null, + tint = if (expanded) TrackProTheme.colors.accent else TrackProTheme.colors.textMuted, + modifier = Modifier.rotate(if (expanded) 180f else 0f) + ) + }, colors = OutlinedTextFieldDefaults.colors( focusedTextColor = TrackProTheme.colors.textPrimary, unfocusedTextColor = TrackProTheme.colors.textPrimary, focusedBorderColor = TrackProTheme.colors.accent, - unfocusedBorderColor = TrackProTheme.colors.sectorLine + unfocusedBorderColor = TrackProTheme.colors.sectorLine, + focusedTrailingIconColor = TrackProTheme.colors.accent, + unfocusedTrailingIconColor = TrackProTheme.colors.textMuted ), modifier = Modifier.fillMaxWidth() ) @@ -164,7 +197,7 @@ fun TrackDropdownMenu( ) { if (tracks.isEmpty()) { DropdownMenuItem( - text = { Text("No tracks found", color = TrackProTheme.colors.textMuted) }, + text = { Text(stringResource(R.string.dropdown_no_tracks), color = TrackProTheme.colors.textMuted) }, onClick = { expanded = false }, enabled = false ) diff --git a/app/src/main/java/com/example/trackpro/extrasForUI/TrackProTheme.kt b/app/src/main/java/com/example/trackpro/extrasForUI/TrackProTheme.kt index 9d94e47..3065593 100644 --- a/app/src/main/java/com/example/trackpro/extrasForUI/TrackProTheme.kt +++ b/app/src/main/java/com/example/trackpro/extrasForUI/TrackProTheme.kt @@ -1,21 +1,21 @@ package com.example.trackpro.extrasForUI +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.darkColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme -import androidx.compose.ui.graphics.Color -import com.example.trackpro.theme.DarkTrackProColors -import com.example.trackpro.theme.LightTrackProColors +import androidx.compose.ui.unit.dp +import com.example.trackpro.theme.PaddockNight +import com.example.trackpro.theme.PaddockSunlight import com.example.trackpro.theme.TrackProColorScheme -private val LocalTrackProColors = staticCompositionLocalOf { DarkTrackProColors } +private val LocalTrackProColors = staticCompositionLocalOf { PaddockNight } -// 2. Create an elegant accessor object for UI code object TrackProTheme { val colors: TrackProColorScheme @Composable @@ -24,66 +24,57 @@ object TrackProTheme { } /** - * Maps our racing-HUD palette onto Material3's ColorScheme so that stock Material3 - * components (OutlinedTextField, DropdownMenu, AlertDialog, etc.) that don't explicitly - * override every color still fall back to something themed instead of Material3's - * default light/purple scheme. + * Stock Material components (text fields, dialogs, menus, switches) read these, so they land + * in Paddock night without per-call overrides. Both contrasts are dark schemes. */ -private fun TrackProColorScheme.toMaterialColorScheme(dark: Boolean): androidx.compose.material3.ColorScheme = - if (dark) { - darkColorScheme( - // primary/secondary/tertiary intentionally all map to the same accent - the - // palette has exactly one. Material needs three slots filled; that is not a - // reason to invent two more brand colors. - primary = accent, - onPrimary = onAccent, - secondary = accent, - onSecondary = onAccent, - tertiary = accent, - onTertiary = onAccent, - background = bgDeep, - onBackground = textPrimary, - surface = bgCard, - onSurface = textPrimary, - surfaceVariant = bgElevated, - onSurfaceVariant = textMuted, - outline = sectorLine, - error = deltaBad, - onError = Color.Black - ) - } else { - lightColorScheme( - primary = accent, - onPrimary = onAccent, - secondary = accent, - onSecondary = onAccent, - tertiary = accent, - onTertiary = onAccent, - background = bgDeep, - onBackground = textPrimary, - surface = bgCard, - onSurface = textPrimary, - surfaceVariant = bgElevated, - onSurfaceVariant = textMuted, - outline = sectorLine, - error = deltaBad, - onError = Color.White - ) - } +private fun TrackProColorScheme.toMaterialColorScheme() = darkColorScheme( + primary = accent, + onPrimary = onAccent, + primaryContainer = accent.copy(alpha = 0.16f), + onPrimaryContainer = accent, + secondary = accent, + onSecondary = onAccent, + tertiary = deltaGood, + onTertiary = onAccent, + background = bgDeep, + onBackground = textPrimary, + surface = bgCard, + onSurface = textPrimary, + surfaceVariant = bgElevated, + onSurfaceVariant = textMuted, + surfaceContainerHigh = bgCard, + surfaceContainerHighest = bgElevated, + outline = sectorLine, + outlineVariant = sectorLine, + error = danger, + onError = textPrimary +) + +private val PaddockShapes = Shapes( + extraSmall = RoundedCornerShape(8.dp), + small = RoundedCornerShape(12.dp), + medium = RoundedCornerShape(16.dp), + large = RoundedCornerShape(24.dp), + extraLarge = RoundedCornerShape(28.dp) +) +/** + * Always dark. [sunlight] swaps in [PaddockSunlight], the high-contrast variant for a phone + * on a mount in direct sun. [darkTheme] is ignored and only kept so older call sites compile. + */ @Composable fun TrackProTheme( - darkTheme: Boolean = isSystemInDarkTheme(), + @Suppress("UNUSED_PARAMETER") darkTheme: Boolean = isSystemInDarkTheme(), + sunlight: Boolean = false, content: @Composable () -> Unit ) { - val colorScheme = if (darkTheme) DarkTrackProColors else LightTrackProColors + val colorScheme = if (sunlight) PaddockSunlight else PaddockNight - CompositionLocalProvider( - LocalTrackProColors provides colorScheme - ) { + CompositionLocalProvider(LocalTrackProColors provides colorScheme) { MaterialTheme( - colorScheme = colorScheme.toMaterialColorScheme(darkTheme), + colorScheme = colorScheme.toMaterialColorScheme(), + shapes = PaddockShapes, content = content ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/trackpro/managerClasses/RecordingService.kt b/app/src/main/java/com/example/trackpro/managerClasses/RecordingService.kt index e78b45f..962527a 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/RecordingService.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/RecordingService.kt @@ -39,6 +39,11 @@ import com.example.trackpro.models.GpsProviderType */ class RecordingService : Service() { + // Its notification is the one piece of text shown outside the app, in the app's language. + override fun attachBaseContext(newBase: android.content.Context) { + super.attachBaseContext(com.example.trackpro.managerClasses.utilities.AppLanguage.wrap(newBase)) + } + override fun onBind(intent: Intent?): IBinder? = null override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -69,14 +74,14 @@ class RecordingService : Service() { private fun ensureChannel() { val manager = getSystemService(NotificationManager::class.java) ?: return - if (manager.getNotificationChannel(CHANNEL_ID) != null) return + // Created or renamed: an existing channel takes the new name, so it follows the app's language. manager.createNotificationChannel( NotificationChannel( CHANNEL_ID, - "Session recording", + getString(R.string.notif_channel), NotificationManager.IMPORTANCE_LOW ).apply { - description = "Shown while a session is being timed, so it keeps running with the screen off." + description = getString(R.string.notif_channel_desc) } ) } @@ -94,8 +99,8 @@ class RecordingService : Service() { } return NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(R.drawable.ic_stat_recording) - .setContentTitle("Recording session") - .setContentText("Timing continues with the screen off.") + .setContentTitle(getString(R.string.notif_title)) + .setContentText(getString(R.string.notif_text)) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setOngoing(true) .setContentIntent(returnToApp) diff --git a/app/src/main/java/com/example/trackpro/managerClasses/utilities/AppLanguage.kt b/app/src/main/java/com/example/trackpro/managerClasses/utilities/AppLanguage.kt new file mode 100644 index 0000000..553b9e5 --- /dev/null +++ b/app/src/main/java/com/example/trackpro/managerClasses/utilities/AppLanguage.kt @@ -0,0 +1,67 @@ +package com.example.trackpro.managerClasses.utilities + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Configuration +import android.content.res.Resources +import android.os.LocaleList +import java.util.Locale + +/** + * The app's own language choice, independent of the phone's: follow the system, or force + * English or Hungarian. Stored as a tag so a new language is one more constant. + * + * Applied in two places. The activity wraps its base context, so everything drawn reads + * the chosen language; the application's resources are updated too, so text produced + * outside a screen (the recording notification, sync messages) follows it as well. + * Changing it recreates the activity - navigation state survives that. + */ +object AppLanguage { + const val SYSTEM = "system" + const val ENGLISH = "en" + const val HUNGARIAN = "hu" + + private const val PREFS = "language_prefs" + private const val KEY = "language" + + fun stored(context: Context): String = + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, SYSTEM) ?: SYSTEM + + fun store(context: Context, tag: String) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, tag).apply() + } + + private fun localeFor(tag: String): Locale = when (tag) { + ENGLISH -> Locale.ENGLISH + HUNGARIAN -> Locale("hu", "HU") + else -> Resources.getSystem().configuration.locales[0] + } + + private fun configured(base: Configuration, locale: Locale): Configuration = + Configuration(base).apply { setLocales(LocaleList(locale)) } + + /** For Activity.attachBaseContext. */ + fun wrap(base: Context): Context { + val locale = localeFor(stored(base)) + // Date and number formatting reads the default locale, not the resources'. + Locale.setDefault(locale) + return base.createConfigurationContext(configured(base.resources.configuration, locale)) + } + + /** Brings the application's resources in line with the stored choice. */ + @Suppress("DEPRECATION") + fun applyTo(context: Context) { + val locale = localeFor(stored(context)) + Locale.setDefault(locale) + val res = context.resources + res.updateConfiguration(configured(res.configuration, locale), res.displayMetrics) + } +} + +/** The activity behind a composition's context, if there is one. */ +tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null +} diff --git a/app/src/main/java/com/example/trackpro/managerClasses/utilities/DateFormatterUtil.kt b/app/src/main/java/com/example/trackpro/managerClasses/utilities/DateFormatterUtil.kt index baa24d6..38c1a5c 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/utilities/DateFormatterUtil.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/utilities/DateFormatterUtil.kt @@ -17,10 +17,18 @@ object DateFormatterUtil { private const val DATE_TIME_PATTERN = "dd MMM yyyy, HH:mm" private const val LOG_TIMESTAMP_PATTERN = "HH:mm:ss.SSS" + // Hungarian writes dates year first and ends the day with a full stop: 2026. szept. 28. + private val hungarian get() = Locale.getDefault().language == "hu" + // Shown to the driver. - fun getDateFormat() = SimpleDateFormat(DATE_PATTERN, Locale.getDefault()) + fun getDateFormat() = SimpleDateFormat(if (hungarian) "yyyy. MMM d." else DATE_PATTERN, Locale.getDefault()) fun getTimeFormat() = SimpleDateFormat(TIME_PATTERN, Locale.getDefault()) - fun getDateTimeFormat() = SimpleDateFormat(DATE_TIME_PATTERN, Locale.getDefault()) + fun getDateTimeFormat() = SimpleDateFormat(if (hungarian) "yyyy. MMM d., HH:mm" else DATE_TIME_PATTERN, Locale.getDefault()) + + /** Day and month, for java.time: "28 Sep" / "szept. 28.". */ + fun dayMonthPattern() = if (hungarian) "MMM d." else "dd MMM" + /** Weekday and time, for java.time: "Monday, 11:24" / "hétfő 11:24". */ + fun weekdayTimePattern() = if (hungarian) "EEEE HH:mm" else "EEEE, HH:mm" /** Read from and written to the rig. Never localised. */ fun getLogTimestampFormat() = SimpleDateFormat(LOG_TIMESTAMP_PATTERN, Locale.ROOT) diff --git a/app/src/main/java/com/example/trackpro/managerClasses/utilities/UnitFormatter.kt b/app/src/main/java/com/example/trackpro/managerClasses/utilities/UnitFormatter.kt index 9516ecf..9a57165 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/utilities/UnitFormatter.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/utilities/UnitFormatter.kt @@ -12,7 +12,7 @@ object UnitFormatter { private const val METERS_TO_FEET = 3.28084 private const val FEET_PER_MILE = 5280.0 - fun speedUnitLabel(metric: Boolean) = if (metric) "KM/H" else "MPH" + fun speedUnitLabel(metric: Boolean) = if (metric) "km/h" else "mph" /** Converts a km/h speed value to the display unit (no formatting). */ fun convertSpeed(kmh: Double, metric: Boolean): Double = diff --git a/app/src/main/java/com/example/trackpro/managerClasses/utilities/WeatherService.kt b/app/src/main/java/com/example/trackpro/managerClasses/utilities/WeatherService.kt index eab7743..eed113e 100644 --- a/app/src/main/java/com/example/trackpro/managerClasses/utilities/WeatherService.kt +++ b/app/src/main/java/com/example/trackpro/managerClasses/utilities/WeatherService.kt @@ -1,5 +1,6 @@ package com.example.trackpro.managerClasses.utilities +import com.example.trackpro.R import android.util.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -104,6 +105,32 @@ object WeatherService { else -> "Unknown" } + /** The same label as [describeCode], as a string resource for the screen's language. */ + @androidx.annotation.StringRes + fun describeCodeRes(code: Int?): Int = when (code) { + null -> R.string.weather_unknown + 0 -> R.string.weather_clear + 1 -> R.string.weather_mainly_clear + 2 -> R.string.weather_partly_cloudy + 3 -> R.string.weather_overcast + 45, 48 -> R.string.weather_fog + 51, 53, 55 -> R.string.weather_drizzle + 56, 57 -> R.string.weather_freezing_drizzle + 61 -> R.string.weather_light_rain + 63 -> R.string.weather_rain + 65 -> R.string.weather_heavy_rain + 66, 67 -> R.string.weather_freezing_rain + 71 -> R.string.weather_light_snow + 73 -> R.string.weather_snow + 75 -> R.string.weather_heavy_snow + 77 -> R.string.weather_snow_grains + 80, 81, 82 -> R.string.weather_rain_showers + 85, 86 -> R.string.weather_snow_showers + 95 -> R.string.weather_thunderstorm + 96, 99 -> R.string.weather_thunderstorm_hail + else -> R.string.weather_unknown + } + /** * Whether these conditions imply a wet surface - the single most important thing to know * when comparing two lap times. Based on measured precipitation or a precipitating code, @@ -123,7 +150,11 @@ object WeatherService { /** Compass point for a wind bearing, e.g. 31 -> "NNE". */ fun windCompass(degrees: Int?): String { if (degrees == null) return "-" - val points = listOf( + // Hungarian names the points északi, keleti, déli, nyugati. + val points = if (java.util.Locale.getDefault().language == "hu") listOf( + "É", "ÉÉK", "ÉK", "KÉK", "K", "KDK", "DK", "DDK", + "D", "DDNy", "DNy", "NyDNy", "Ny", "NyÉNy", "ÉNy", "ÉÉNy" + ) else listOf( "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW" ) diff --git a/app/src/main/java/com/example/trackpro/models/SessionBestLap.kt b/app/src/main/java/com/example/trackpro/models/SessionBestLap.kt new file mode 100644 index 0000000..45834e4 --- /dev/null +++ b/app/src/main/java/com/example/trackpro/models/SessionBestLap.kt @@ -0,0 +1,10 @@ +package com.example.trackpro.models + +/** + * A session's fastest completed lap, as stored ("MM:SS.hh"). One row per session that has + * completed at least one lap. + */ +data class SessionBestLap( + val sessionId: Long, + val bestLap: String +) diff --git a/app/src/main/java/com/example/trackpro/online/AccountRestore.kt b/app/src/main/java/com/example/trackpro/online/AccountRestore.kt new file mode 100644 index 0000000..b76d567 --- /dev/null +++ b/app/src/main/java/com/example/trackpro/online/AccountRestore.kt @@ -0,0 +1,465 @@ +package com.example.trackpro.online + +import com.example.trackpro.dao.SyncDao +import com.example.trackpro.dataClasses.LapTimeData +import com.example.trackpro.dataClasses.RemoteLink +import com.example.trackpro.dataClasses.RemoteLink.Companion.KIND_PREMADE_TRACK +import com.example.trackpro.dataClasses.RemoteLink.Companion.KIND_SESSION +import com.example.trackpro.dataClasses.RemoteLink.Companion.KIND_TRACK +import com.example.trackpro.dataClasses.RemoteLink.Companion.KIND_VEHICLE +import com.example.trackpro.dataClasses.SectorTimeData +import com.example.trackpro.dataClasses.SessionData +import com.example.trackpro.dataClasses.TrackCoordinatesData +import com.example.trackpro.dataClasses.TrackMainData +import com.example.trackpro.dataClasses.TrackPublication +import com.example.trackpro.managerClasses.utilities.toLapTimeString +import com.example.trackpro.models.GpsProviderType +import java.time.Instant +import java.time.OffsetDateTime +import java.util.UUID + +/** What restoring did in one run. Folded into the [SyncReport] the Settings screen shows. */ +class RestoreTally { + var downloaded = 0 + var failed = 0 + var problem: String? = null + + fun fail(message: String?) { + failed++ + if (problem == null) problem = message ?: "Something could not be restored." + } +} + +/** + * Brings the driver's own tracks and sessions from their TrackBoard account onto this phone: + * how a new phone or a reinstall gets its history back, and how a session driven with another + * phone shows up on this one. + * + * Everything restored is linked to the record it came from, with the fingerprint the upload + * side computes for it. To the rest of sync it then looks exactly like something this phone + * sent itself: unchanged, it is never sent back; edited here, it goes up as an update to the + * same record, never as a copy. + * + * The account holds less than the phone recorded. A session is there only if it was shared + * to a leaderboard, and it carries laps, sectors and weather but not the GPS trace, which + * never leaves the phone - so a restored session has its times but no map or speed data. + * + * Nothing here deletes a local row. A session or track that disappears from the account is + * left alone on the phone, as the garage does with cars. + */ +class AccountRestore( + private val api: TrackBoardApi, + private val auth: AuthRepository, + private val dao: SyncDao, + private val premade: PremadeTracks, + private val appVersion: String?, + /** Runs a block atomically: Room's withTransaction in the app, a plain call in tests. */ + private val transaction: suspend (suspend () -> Unit) -> Unit, + private val clock: () -> Long, +) { + private class LocalTrack(val track: TrackMainData, val points: List) { + val fingerprint by lazy { PayloadMapper.timingFingerprint(points) } + } + + /** Every local track with its points, read once per run and only if something needs it. */ + private var localTracks: List? = null + + private suspend fun localTracks(): List = + localTracks ?: dao.getTracks().map { LocalTrack(it, dao.getTrackPoints(it.trackId)) }.also { localTracks = it } + + // ── Tracks ── + + /** + * The driver's own tracks, published and private. Runs before [restoreSessions], which + * needs the tracks to put sessions on. + */ + suspend fun restoreTracks(tally: RestoreTally) { + localTracks = null + val remote = listing(tally, "your tracks") { token, page -> + api.listMyTracks(token, page, PAGE_SIZE).let { it.items to it.totalCount } + } ?: return + + // Linked either way already, including tracks deleted here that the account had to + // keep for other drivers' laps: those must not come back. + val known = (dao.getLinks(KIND_TRACK) + dao.getLinks(KIND_PREMADE_TRACK)).mapTo(mutableSetOf()) { it.remoteId } + val missing = remote.filter { it.id !in known && !isPremadeId(it.id) } + if (missing.isEmpty()) return + + // A premade track created by this driver lists as theirs. Every install has it + // already, bundled, and it is not theirs to copy. + val bundled = localTracks().mapNotNullTo(mutableSetOf()) { premade.remoteIdFor(it.track, it.points) } + for (summary in missing) { + if (summary.id in bundled) continue + attempt(tally) { + val detail = auth.authorized { token -> api.getTrackDetail(token, summary.id) } ?: return@attempt + restoreTrack(detail, tally) + } + } + } + + private suspend fun restoreTrack(detail: TrackDetail, tally: RestoreTally) { + val points = detail.points.sortedBy { it.seq }.map { p -> + TrackCoordinatesData( + trackId = 0, + latitude = p.latitude, + longitude = p.longitude, + altitude = p.altitude, + isStartPoint = p.isStartPoint, + isSectorPoint = p.isSectorPoint, + sectorIndex = p.sectorIndex, + ) + } + // Nothing a timer could use; the server never accepts fewer, but be sure. + if (points.size < 2) return + val published = detail.visibility == ApiTrackVisibility.Published + + // The same track built by hand on both phones: link the local one rather than doubling it. + val linked = dao.getLinks(KIND_TRACK).mapTo(mutableSetOf()) { it.localId } + val fingerprint = PayloadMapper.timingFingerprint(points) + val twin = localTracks().firstOrNull { + it.track.trackId !in linked && + it.track.trackName == detail.name && + it.fingerprint == fingerprint && + premade.remoteIdFor(it.track, it.points) == null + } + if (twin != null) { + transaction { + if (published) dao.publish(TrackPublication(twin.track.trackId, clock())) + // Not yet compared: the next upload settles any difference, the phone's copy winning. + dao.putLink(RemoteLink(KIND_TRACK, twin.track.trackId, detail.id, PAIRED, clock())) + } + return + } + + transaction { + val trackId = dao.insertTrack( + TrackMainData( + trackName = detail.name, + // The contract carries metres; the app stores kilometres. + totalLength = detail.lengthMeters?.let { it / 1000 }, + country = detail.country, + type = if (detail.type == ApiTrackType.Sprint) "Sprint" else "Circuit", + ) + ) + dao.insertTrackPoints(points.map { it.copy(trackId = trackId) }) + if (published) dao.publish(TrackPublication(trackId, clock())) + + // Fingerprinted from the rows as stored, exactly as an upload would read them. + val visibility = if (published) ApiTrackVisibility.Published else ApiTrackVisibility.Private + val stored = dao.getTrack(trackId)?.let { PayloadMapper.track(it, dao.getTrackPoints(trackId), visibility) } + dao.putLink(RemoteLink(KIND_TRACK, trackId, detail.id, stored?.let(PayloadMapper::hash), clock())) + } + localTracks = null + tally.downloaded++ + } + + // ── Sessions ── + + /** + * The driver's sessions. Only called while leaderboard sharing is on: the account's + * sessions are exactly the shared ones, and restoring them with sharing off would have the + * next sync withdraw them from the leaderboard - on every phone at once. + */ + suspend fun restoreSessions(tally: RestoreTally) { + val remote = listing(tally, "your sessions") { token, page -> + api.listSessions(token, page, PAGE_SIZE).let { it.items to it.totalCount } + } ?: return + + // Linked already, including sessions deleted here whose withdrawal has not gone + // through yet: those must not come back either. + val links = dao.getLinks(KIND_SESSION) + val cars = carIndex() + reattachCars(remote, links, cars, tally) + + val known = links.mapTo(mutableSetOf()) { it.remoteId } + val missing = remote.filter { it.id !in known && it.trackId != null && it.lapCount > 0 } + if (missing.isEmpty()) return + + val tracks = trackIndex() + // The sessions here by the millisecond they started, which no two different sessions + // share. The account can hold one session twice - a phone that lost its links and + // posted it again, or two phones with the same history - so a record is matched + // against every session here, linked or not, before anything is added. + val here = dao.getFinishedTrackSessions().groupBy { it.startTime } + val linkedHere = links.mapTo(mutableSetOf()) { it.localId } + + for (summary in missing) { + val startTime = parseInstant(summary.startedAt) ?: continue + val copies = here[startTime] + if (copies != null) { + attempt(tally) { pair(copies, summary.id, linkedHere) } + continue + } + + // A track this phone does not have, or one it has but does not share: a session + // restored onto it would be withdrawn again by the next sync, so it stays online. + val localTrackId = tracks[summary.trackId] ?: continue + attempt(tally) { + val detail = auth.authorized { token -> api.getSession(token, summary.id) } ?: return@attempt + if (restoreSession(detail, summary.trackId!!, localTrackId, cars, tally)) tally.downloaded++ + } + } + } + + /** + * The account's record of a session this phone already has. A copy here without a link - + * the links were lost, say - takes the record, so its next upload updates it rather than + * posting yet another. A copy already linked to a different record means the account holds + * the session twice; that is left as it is, and nothing is added here. + */ + private suspend fun pair(copies: List, remoteId: String, linkedHere: MutableSet) { + val twin = copies.firstOrNull { it.id !in linkedHere } ?: return + // Only a copy this phone could post itself, or the next sync would withdraw the + // account's record for it. The planner's own test, so the two can never disagree. + val sendable = PayloadMapper.session(twin, dao.getLaps(twin.id), emptyList(), "planning", null, null) != null + if (!sendable) return + dao.putLink(RemoteLink(KIND_SESSION, twin.id, remoteId, PAIRED, clock())) + linkedHere += twin.id + } + + /** + * Removes what the first version of this restore did when the account held a session + * twice: it brought the second record down as a second copy. Such a copy is unmistakable - + * the same start as another session here, linked, and without a GPS trace, which no + * restored session has - and dropping it with its link loses nothing: the account keeps + * both records, and the copy kept here stays linked to one of them. + * + * Only ever local. Nothing is withdrawn from the account. Run before sync plans its + * uploads, or a copy dropped mid-run would be posted as a new record. + */ + suspend fun dropRestoredDuplicates() { + val linked = dao.getLinks(KIND_SESSION).mapTo(mutableSetOf()) { it.localId } + val groups = dao.getFinishedTrackSessions().groupBy { it.startTime }.values.filter { it.size > 1 } + for (copies in groups) { + // The oldest row stays: the original, or at least the first copy made. + for (extra in copies.sortedBy { it.id }.drop(1)) { + if (extra.id !in linked || dao.hasGpsTrace(extra.id)) continue + transaction { + dao.deleteSession(extra.id) + dao.deleteLink(KIND_SESSION, extra.id) + } + } + } + } + + private suspend fun restoreSession( + detail: SessionDetail, + remoteTrackId: String, + localTrackId: Long, + cars: MutableMap, + tally: RestoreTally, + ): Boolean { + val startTime = parseInstant(detail.startedAt) ?: return false + val laps = detail.laps + .filter { it.lapNumber >= 1 && it.timeMs > 0 } + .distinctBy { it.lapNumber } + .sortedBy { it.lapNumber } + if (laps.isEmpty()) return false + + // Sync only posts finished sessions, so there always is an end; should one be missing, + // the laps back to back stand in, since a session without an end is never synced again. + val endTime = detail.endedAt?.let(::parseInstant)?.takeIf { it >= startTime } + ?: (startTime + laps.sumOf { it.timeMs.toLong() }) + val vehicleId = detail.vehicleId?.let { carFor(it, cars, tally) } + val weather = detail.weather + + transaction { + val sessionId = dao.insertSession( + SessionData( + startTime = startTime, + endTime = endTime, + eventType = detail.name, + vehicleId = vehicleId, + trackId = localTrackId, + weatherTempC = weather?.tempC, + weatherHumidityPct = weather?.humidityPct, + weatherPrecipitationMm = weather?.precipitationMm, + weatherCode = weather?.weatherCode, + weatherWindKph = weather?.windKph, + weatherWindDirDeg = weather?.windDirDeg, + weatherPressureHpa = weather?.pressureHpa, + voided = detail.voided, + gpsSource = detail.gpsSource.toLocal().name, + ) + ) + for (lap in laps) { + val lapId = dao.insertLap( + LapTimeData( + sessionid = sessionId, + lapnumber = lap.lapNumber, + laptime = lap.timeMs.toLong().toLapTimeString(), + signalGap = lap.signalGap, + ) + ) + if (lap.sectors.isNotEmpty()) { + dao.insertSectors(lap.sectors.map { SectorTimeData(lapid = lapId, sectorIndex = it.sectorIndex, splitTimeMs = it.splitMs.toLong()) }) + } + } + + // Fingerprinted from the rows as stored, with the ids an upload would use: the + // account's own track, and its car - absent only if the account no longer has it. + val session = dao.getSession(sessionId) + val storedLaps = dao.getLaps(sessionId) + val stored = session?.let { + PayloadMapper.session( + it, storedLaps, dao.getSectors(storedLaps.map { lap -> lap.id }), + remoteTrackId, detail.vehicleId.takeIf { vehicleId != null }, appVersion, + ) + } + dao.putLink(RemoteLink(KIND_SESSION, sessionId, detail.id, stored?.let(PayloadMapper::hash), clock())) + } + return true + } + + /** + * Server track id to local track id, for the tracks a restored session would stay online + * on: premade ones, and the driver's own published ones. + */ + private suspend fun trackIndex(): Map { + val index = mutableMapOf() + val published = dao.getPublishedTrackIds().toSet() + for (link in dao.getLinks(KIND_TRACK)) { + if (link.localId in published) index[link.remoteId] = link.localId + } + for (local in localTracks()) { + premade.remoteIdFor(local.track, local.points)?.let { index.putIfAbsent(it, local.track.trackId) } + } + return index + } + + /** Server car id to local car id, for the cars still on this phone. */ + private suspend fun carIndex(): MutableMap = + dao.getLinks(KIND_VEHICLE) + .filter { dao.getVehicle(it.localId) != null } + .associateTo(mutableMapOf()) { it.remoteId to it.localId } + + /** + * The local car for an account car, brought down if this phone does not have it - even a + * car deleted here, which the garage otherwise leaves on the account alone. A session + * driven in it is on this phone now: without its car it reads "Unknown car", and the first + * edit made to it here would post it without the car and take the car off it online too. + * + * Null only when the account no longer has the car. + */ + private suspend fun carFor(remoteId: String, cars: MutableMap, tally: RestoreTally): Long? { + cars[remoteId]?.let { return it } + val remote = auth.authorized { token -> api.getVehicle(token, remoteId) } ?: return null + + var localId = 0L + transaction { + localId = dao.insertVehicle(remote.applyTo(null)) + // A car deleted here is remembered under its old local id, and the garage would + // keep treating it as deleted; it is here again now. + for (stale in dao.getLinks(KIND_VEHICLE).filter { it.remoteId == remoteId }) { + dao.deleteLink(KIND_VEHICLE, stale.localId) + } + // The same fingerprint the garage gives a car it imports, so it takes over from + // here - its photo included - as if it had brought the car down itself. + dao.putLink(RemoteLink(KIND_VEHICLE, localId, remoteId, PayloadMapper.hash(remote.toWrite()), clock())) + } + cars[remoteId] = localId + tally.downloaded++ + return localId + } + + /** + * Gives back their car to sessions brought down without it. The first version of this + * restore left a session's car out whenever the car was not on this phone; the account + * still names it, so the car comes down now and the session gets it again. + */ + private suspend fun reattachCars( + remote: List, + links: List, + cars: MutableMap, + tally: RestoreTally, + ) { + val byRemote = links.associateBy { it.remoteId } + for (summary in remote) { + val remoteCar = summary.vehicleId ?: continue + val remoteTrack = summary.trackId ?: continue + val link = byRemote[summary.id] ?: continue + val session = dao.getSession(link.localId) ?: continue + if (session.vehicleId != null) continue + + attempt(tally) { + val carId = carFor(remoteCar, cars, tally) ?: return@attempt + val laps = dao.getLaps(session.id) + val sectors = dao.getSectors(laps.map { it.id }) + val withoutCar = PayloadMapper.session(session, laps, sectors, remoteTrack, null, appVersion) + transaction { + dao.setSessionVehicle(session.id, carId) + // In step with the account before, so in step now: the account had the car + // all along. A change made here and not yet sent keeps its old fingerprint, + // and goes up at the next upload - now with its car. + if (withoutCar != null && PayloadMapper.hash(withoutCar) == link.uploadedHash) { + val withCar = PayloadMapper.session(session.copy(vehicleId = carId), laps, sectors, remoteTrack, remoteCar, appVersion) + dao.putLink(link.copy(uploadedHash = withCar?.let(PayloadMapper::hash))) + } + } + } + } + } + + // ── Plumbing ── + + /** + * Every page of a listing. Null when the server refused it: that costs this run the + * restore, not the uploads that follow. + */ + private suspend fun listing( + tally: RestoreTally, + what: String, + fetch: suspend (token: String, page: Int) -> Pair, Long>, + ): List? { + val all = mutableListOf() + var page = 1 + while (true) { + val (items, total) = try { + auth.authorized { token -> fetch(token, page) } + } catch (e: ApiException) { + if (e.status == 401) throw e + tally.fail("Could not read $what from TrackBoard: ${e.message}") + return null + } + all += items + if (items.isEmpty() || all.size >= total) return all + page++ + } + } + + private suspend fun attempt(tally: RestoreTally, block: suspend () -> Unit) { + try { + block() + } catch (e: SyncProblem) { + tally.fail(e.message) + } catch (e: ApiException) { + if (e.status == 401) throw e + tally.fail(e.message) + } + } + + companion object { + private const val PAGE_SIZE = 100 + + /** Linked to a matching local record that has not been compared yet. */ + const val PAIRED = "paired" + + /** + * Premade track ids are name-based (version 3) UUIDs, derived the same on every + * install; everything the app creates itself is random (version 4). + */ + fun isPremadeId(id: String): Boolean = + runCatching { UUID.fromString(id).version() == 3 }.getOrDefault(false) + + /** .NET may send an offset rather than "Z", which Instant.parse rejects before API 34. */ + fun parseInstant(value: String): Long? = + runCatching { Instant.parse(value).toEpochMilli() }.getOrNull() + ?: runCatching { OffsetDateTime.parse(value).toInstant().toEpochMilli() }.getOrNull() + + private fun ApiGpsSource.toLocal(): GpsProviderType = when (this) { + ApiGpsSource.Wifi -> GpsProviderType.WIFI + ApiGpsSource.Bluetooth -> GpsProviderType.BLUETOOTH + ApiGpsSource.PhoneGps -> GpsProviderType.PHONE_GPS + } + } +} diff --git a/app/src/main/java/com/example/trackpro/online/ApiModels.kt b/app/src/main/java/com/example/trackpro/online/ApiModels.kt index 2eaf1df..3848620 100644 --- a/app/src/main/java/com/example/trackpro/online/ApiModels.kt +++ b/app/src/main/java/com/example/trackpro/online/ApiModels.kt @@ -47,6 +47,10 @@ data class LoginRequest(val email: String, val password: String) @Serializable data class RefreshRequest(val refreshToken: String) +/** Deleting the account takes the password as well as the token, so a stolen token cannot do it. */ +@Serializable +data class DeleteAccountRequest(val password: String) + @Serializable data class ApiUser(val id: String, val email: String, val displayName: String, val role: String) @@ -159,6 +163,28 @@ data class TrackSummary( val geometryLocked: Boolean = false, ) +/** One page of the caller's own tracks (`GET /tracks?mine=true`), without their geometry. */ +@Serializable +data class TrackPage( + val items: List, + val page: Int, + val pageSize: Int, + val totalCount: Long, +) + +/** A whole track as the server returns it. Points arrive in `seq` order. */ +@Serializable +data class TrackDetail( + val id: String, + val name: String, + val country: String = "", + val type: ApiTrackType = ApiTrackType.Circuit, + val visibility: ApiTrackVisibility, + val lengthMeters: Double? = null, + /** Same shape up and down, so the write type serves for reading too. */ + val points: List = emptyList(), +) + // ── Sessions ── @Serializable @@ -198,6 +224,39 @@ data class SessionWrite( val laps: List, ) +/** A session in the caller's list (`GET /sessions`): enough to decide whether to fetch it. */ +@Serializable +data class SessionSummary( + val id: String, + val startedAt: String, + val trackId: String? = null, + val vehicleId: String? = null, + val lapCount: Int = 0, +) + +@Serializable +data class SessionPage( + val items: List, + val page: Int, + val pageSize: Int, + val totalCount: Long, +) + +/** A whole session as the server returns it. Laps keep the upload's shape; extra fields are ignored. */ +@Serializable +data class SessionDetail( + val id: String, + val name: String, + val startedAt: String, + val endedAt: String? = null, + val trackId: String? = null, + val vehicleId: String? = null, + val gpsSource: ApiGpsSource, + val voided: Boolean = false, + val weather: WeatherWrite? = null, + val laps: List = emptyList(), +) + // ── Leaderboards ── @Serializable @@ -309,3 +368,83 @@ data class ProblemDetails( val code: String? = null, val errors: Map>? = null, ) + +// ── Events (track days) ── + +@Serializable +enum class ApiEventStatus { + @SerialName("Upcoming") Upcoming, + @SerialName("Live") Live, + @SerialName("Finished") Finished, +} + +@Serializable +data class EventGroup(val id: String, val name: String) + +@Serializable +data class EventSummary( + val id: String, + val name: String, + val trackId: String, + val trackName: String, + val trackCountry: String = "", + val startsAt: String, + val endsAt: String, + val status: ApiEventStatus, + val hostDisplayName: String = "", + val entryCount: Int = 0, + val isHost: Boolean = false, + val isJoined: Boolean = false, + val myGroupId: String? = null, + /** Only sent to the host. */ + val joinCode: String? = null, +) + +@Serializable +data class EventEntry(val userId: String, val displayName: String, val groupId: String? = null) + +@Serializable +data class EventDetail( + val event: EventSummary, + val groups: List = emptyList(), + val entries: List = emptyList(), +) + +@Serializable +data class JoinEventRequest(val code: String, val groupId: String? = null) + +@Serializable +data class SetEntryGroupRequest(val groupId: String?) + +@Serializable +data class EventBoardEntry( + val rank: Int? = null, + val groupRank: Int? = null, + val userId: String, + val displayName: String, + val groupId: String? = null, + val bestLapMs: Int? = null, + val gapToLeaderMs: Int? = null, + val gapToGroupLeaderMs: Int? = null, + val lapCount: Int = 0, + val lastLapMs: Int? = null, + val lastLapIsBest: Boolean = false, + val onTrack: Boolean = false, + val lastActivityAt: String? = null, + val vehicle: LeaderboardVehicle? = null, + val gpsSource: ApiGpsSource? = null, +) + +@Serializable +data class EventBoard( + val eventId: String, + val name: String, + val trackId: String, + val trackName: String, + val status: ApiEventStatus, + val startsAt: String, + val endsAt: String, + val groups: List = emptyList(), + val entries: List = emptyList(), + val generatedAt: String, +) diff --git a/app/src/main/java/com/example/trackpro/online/EventRepository.kt b/app/src/main/java/com/example/trackpro/online/EventRepository.kt new file mode 100644 index 0000000..e306c21 --- /dev/null +++ b/app/src/main/java/com/example/trackpro/online/EventRepository.kt @@ -0,0 +1,154 @@ +package com.example.trackpro.online + +import android.content.Context +import com.example.trackpro.dao.SyncDao +import com.example.trackpro.dataClasses.RemoteLink +import com.example.trackpro.dataClasses.TrackCoordinatesData +import com.example.trackpro.dataClasses.TrackMainData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.ListSerializer +import java.time.Instant + +/** + * An event this driver has joined, as the phone remembers it. Enough to decide, offline and + * mid-session, whether a lap belongs on an event board. + */ +@Serializable +data class JoinedEvent( + val id: String, + val name: String, + /** The server track the event runs on. */ + val trackId: String, + val trackName: String, + val startsAtMs: Long, + val endsAtMs: Long, + val groupId: String? = null, +) { + fun covers(atMs: Long) = atMs in startsAtMs..endsAtMs +} + +/** + * Track days on TrackBoard: joining with a code, the list of joined events, and the event + * board. Joining is the driver's consent to share every lap they drive on the event's track + * during the event — private or not — so [SyncEngine] reads [joined] to decide what goes up. + */ +class EventRepository( + context: Context, + private val api: TrackBoardEventsApi, + private val auth: AuthRepository, + private val dao: SyncDao, + /** The server id of a local track, whichever way it is linked. */ + private val remoteTrackIdFor: suspend (Long) -> String?, + private val transaction: suspend (suspend () -> Unit) -> Unit, + private val clock: () -> Long = System::currentTimeMillis, +) { + private val prefs = context.getSharedPreferences("trackboard_events", Context.MODE_PRIVATE) + private val serializer = ListSerializer(JoinedEvent.serializer()) + + private val _joined = MutableStateFlow(read()) + + /** Joined events, kept on the phone so live uploads work without asking the server first. */ + val joined: StateFlow> = _joined.asStateFlow() + + /** The joined event running on [remoteTrackId] at [atMs], if any. */ + fun eventAt(remoteTrackId: String, atMs: Long): JoinedEvent? = + _joined.value.firstOrNull { it.trackId == remoteTrackId && it.covers(atMs) } + + /** Events the driver hosts or joined, newest first; also refreshes [joined]. */ + suspend fun refresh(): List { + val events = auth.authorized { token -> api.myEvents(token) } + remember(events.filter { it.isJoined }.mapNotNull { it.toJoined() }) + return events + } + + suspend fun lookUp(code: String): EventDetail = auth.authorized { token -> api.eventByCode(token, code) } + + suspend fun join(code: String, groupId: String?): EventDetail { + val detail = auth.authorized { token -> api.joinEvent(token, code, groupId) } + detail.event.toJoined()?.let { joined -> remember(_joined.value.filterNot { it.id == joined.id } + joined) } + return detail + } + + suspend fun leave(eventId: String) { + val userId = (auth.state.value as? AccountState.SignedIn)?.userId ?: return + auth.authorized { token -> api.leaveEvent(token, eventId, userId) } + remember(_joined.value.filterNot { it.id == eventId }) + } + + suspend fun board(eventId: String): EventBoard? = api.eventBoard(auth.currentAccessTokenOrNull(), eventId) + + /** Forgets every joined event. Used on sign-out. */ + fun clear() = remember(emptyList()) + + // ── The event's track ── + + /** The local track timed against the event's gates, or null when the phone does not have it. */ + suspend fun localTrackFor(remoteTrackId: String): Long? = + dao.getTracks().firstOrNull { remoteTrackIdFor(it.trackId) == remoteTrackId }?.trackId + + /** + * Downloads a published track so the driver is timed on exactly the gates everyone else on + * the board is. Linked as shared: it is never uploaded as the driver's own, and deleting the + * local copy never touches the server's. + */ + suspend fun downloadTrack(remoteTrackId: String): Long { + localTrackFor(remoteTrackId)?.let { return it } + val detail = api.publishedTrack(remoteTrackId) + ?: throw SyncProblem("That track is no longer published on TrackBoard.") + val points = detail.points.sortedBy { it.seq }.map { p -> + TrackCoordinatesData( + trackId = 0, + latitude = p.latitude, + longitude = p.longitude, + altitude = p.altitude, + isStartPoint = p.isStartPoint, + isSectorPoint = p.isSectorPoint, + sectorIndex = p.sectorIndex, + ) + } + if (points.size < 2) throw SyncProblem("That track has no usable outline.") + + var localId = -1L + transaction { + localId = dao.insertTrack( + TrackMainData( + trackName = detail.name, + // The contract carries metres; the app stores kilometres. + totalLength = detail.lengthMeters?.let { it / 1000 }, + country = detail.country, + type = if (detail.type == ApiTrackType.Sprint) "Sprint" else "Circuit", + ) + ) + dao.insertTrackPoints(points.map { it.copy(trackId = localId) }) + dao.putLink(RemoteLink(RemoteLink.KIND_SHARED_TRACK, localId, remoteTrackId, uploadedHash = "shared", uploadedAt = clock())) + } + return localId + } + + // ── Storage ── + + private fun remember(events: List) { + prefs.edit().putString(KEY, OkHttpTrackBoardApi.json.encodeToString(serializer, events)).apply() + _joined.value = events + } + + private fun read(): List = + prefs.getString(KEY, null) + ?.let { runCatching { OkHttpTrackBoardApi.json.decodeFromString(serializer, it) }.getOrNull() } + .orEmpty() + + private companion object { + const val KEY = "joined" + } +} + +private fun EventSummary.toJoined(): JoinedEvent? { + val starts = parseInstant(startsAt) ?: return null + val ends = parseInstant(endsAt) ?: return null + return JoinedEvent(id, name, trackId, trackName, starts, ends, myGroupId) +} + +internal fun parseInstant(value: String): Long? = runCatching { Instant.parse(value).toEpochMilli() }.getOrNull() diff --git a/app/src/main/java/com/example/trackpro/online/GarageSync.kt b/app/src/main/java/com/example/trackpro/online/GarageSync.kt index 9e0417c..b78acf5 100644 --- a/app/src/main/java/com/example/trackpro/online/GarageSync.kt +++ b/app/src/main/java/com/example/trackpro/online/GarageSync.kt @@ -291,26 +291,26 @@ class GarageSync( manufacturer.trim().equals(local.manufacturer.trim(), ignoreCase = true) && model.trim().equals(local.model.trim(), ignoreCase = true) && year == local.year - - /** The account's copy as a local row, keeping the local id and photo when there is one. */ - private fun VehicleResponse.applyTo(local: VehicleInformationData?) = VehicleInformationData( - vehicleId = local?.vehicleId ?: 0, - manufacturer = manufacturer, - model = model, - year = year, - engineType = engineType, - horsepower = horsepower, - torque = torque, - weight = weight, - topSpeed = topSpeed, - acceleration = acceleration, - drivetrain = drivetrain, - fuelType = fuelType, - tireType = tireType, - fuelCapacity = fuelCapacity, - transmission = transmission, - suspensionType = suspensionType, - photoFile = local?.photoFile, - ) } } + +/** The account's copy as a local row, keeping the local id and photo when there is one. */ +internal fun VehicleResponse.applyTo(local: VehicleInformationData?) = VehicleInformationData( + vehicleId = local?.vehicleId ?: 0, + manufacturer = manufacturer, + model = model, + year = year, + engineType = engineType, + horsepower = horsepower, + torque = torque, + weight = weight, + topSpeed = topSpeed, + acceleration = acceleration, + drivetrain = drivetrain, + fuelType = fuelType, + tireType = tireType, + fuelCapacity = fuelCapacity, + transmission = transmission, + suspensionType = suspensionType, + photoFile = local?.photoFile, +) diff --git a/app/src/main/java/com/example/trackpro/online/MessageText.kt b/app/src/main/java/com/example/trackpro/online/MessageText.kt new file mode 100644 index 0000000..a595afd --- /dev/null +++ b/app/src/main/java/com/example/trackpro/online/MessageText.kt @@ -0,0 +1,133 @@ +package com.example.trackpro.online + +import java.util.Locale + +/** + * Messages that reach the screen in English, shown in the app's language. + * + * Two sources write them. TrackBoard answers in English, and the sync layer's own texts + * ("Could not reach the TrackBoard server.") are part of its tested behaviour and are also + * stored - a car's last sync error lives in the database. Translating where they are made + * would need a Context in code that has none and would break both. So they stay English + * where they are made and stored, and are translated here, at the point they are shown. + * + * A message is matched whole, line by line. Parts a pattern captures (a car's name, a + * server reason) are translated again in case they are messages themselves. Anything not + * listed is shown as it came - a server reason nobody has translated yet is still better + * than nothing. + */ +object MessageText { + + fun localize(message: String?): String? { + if (message == null || Locale.getDefault().language != "hu") return message + return message.lines().joinToString("\n") { translate(it) } + } + + private fun translate(line: String): String { + val text = line.trim() + for ((pattern, template) in HUNGARIAN) { + val match = pattern.matchEntire(text) ?: continue + var out = template + // Highest group first, so $1 never eats the start of $10. + for (i in match.groupValues.lastIndex downTo 1) { + out = out.replace("$$i", translate(match.groupValues[i])) + } + return out + } + return line + } + + private fun p(regex: String, hungarian: String) = Regex(regex) to hungarian + + private val HUNGARIAN: List> = listOf( + // ── Transport and server status (TrackBoardApi) ───────────────────── + p("""Could not reach the TrackBoard server\.""", "Nem sikerült elérni a TrackBoard szervert."), + p("""No TrackBoard server is set\. Add one in Settings\.""", "Nincs beállítva TrackBoard szerver. Add meg a Beállításokban."), + p("""No TrackBoard server is set\.""", "Nincs beállítva TrackBoard szerver."), + p("""The TrackBoard server address is not a valid URL\.""", "A TrackBoard szerver címe nem érvényes URL."), + p("""The server returned an unusable upload address\.""", "A szerver használhatatlan feltöltési címet adott."), + p("""Photo storage refused the upload \((\d+)\)\.""", "A fotótár visszautasította a feltöltést ($1)."), + p("""Could not download a photo \((\d+)\)\.""", "Nem sikerült letölteni egy fotót ($1)."), + p("""Could not reach photo storage\.""", "Nem sikerült elérni a fotótárat."), + p("""Not signed in, or the sign-in has expired\.""", "Nem vagy bejelentkezve, vagy lejárt a bejelentkezés."), + p("""That belongs to another account\.""", "Ez egy másik fiókhoz tartozik."), + p("""Not found on the server\.""", "Nem található a szerveren."), + p("""Too many attempts\. Wait a minute and try again\.""", "Túl sok próbálkozás. Várj egy percet, és próbáld újra."), + p("""The TrackBoard server had a problem\. Try again later\.""", "Hiba történt a TrackBoard szerveren. Próbáld újra később."), + p("""The server refused the request \((\d+)\)\.""", "A szerver elutasította a kérést ($1)."), + p("""Not signed in\.""", "Nem vagy bejelentkezve."), + p("""Not signed in to TrackBoard\.""", "Nem vagy bejelentkezve a TrackBoardba."), + + // ── Sync, restore and garage ───────────────────────────────────────── + p("""Something could not be restored\.""", "Valamit nem sikerült visszaállítani."), + p("""Something could not be synced\.""", "Valamit nem sikerült szinkronizálni."), + p("""A vehicle could not be synced\.""", "Egy autót nem sikerült szinkronizálni."), + p("""Could not read your tracks from TrackBoard: (.*)""", "Nem sikerült beolvasni a pályáidat a TrackBoardról: $1"), + p("""Could not read your sessions from TrackBoard: (.*)""", "Nem sikerült beolvasni a meneteidet a TrackBoardról: $1"), + p("""Could not read (.+) from TrackBoard: (.*)""", "Nem sikerült beolvasni a TrackBoardról ($1): $2"), + p(""""(.+)" can't be published: it needs at least two valid track points\.""", "A(z) „$1” nem tehető közzé: legalább két érvényes pályapont kell hozzá."), + p(""""(.+)" can't be shared: its bundled geometry is incomplete\.""", "A(z) „$1” nem osztható meg: a beépített nyomvonala hiányos."), + p(""""(.+)" isn't available on TrackBoard right now\.""", "A(z) „$1” most nem érhető el a TrackBoardon."), + p("""This account has reached its limit\.""", "Ez a fiók elérte a korlátját."), + p("""The server rejected it\.""", "A szerver nem fogadta el."), + p("""The server rejected (.+)\.""", "A szerver nem fogadta el: $1."), + p("""(.+) is missing details the account needs, so it stays on this phone\.""", "A(z) $1 adataiból hiányzik, ami a fiókhoz kell, ezért ezen a telefonon marad."), + p("""The account refused (.+)\.""", "A fiók nem fogadta el: $1."), + p("""Removed from your account on another device\. Kept on this phone\.""", "Egy másik eszközön törölted a fiókodból. Ezen a telefonon megmaradt."), + p("""This server does not store photos, so they stay on this phone\.""", "Ez a szerver nem tárol fotókat, ezért azok ezen a telefonon maradnak."), + p("""The photo of (.+) could not be synced: (.*)""", "A(z) $1 fotóját nem sikerült szinkronizálni: $2"), + p("""That track is no longer published on TrackBoard\.""", "Ez a pálya már nincs közzétéve a TrackBoardon."), + p("""That track has no usable outline\.""", "Ennek a pályának nincs használható nyomvonala."), + + // ── Profile and account ────────────────────────────────────────────── + p("""Offline\. Showing what was last synced\.""", "Nincs kapcsolat. A legutóbb szinkronizált adatok látszanak."), + p("""This server has no profiles yet\. Showing this phone's records\.""", "Ezen a szerveren még nincsenek profilok. A telefon saját adatai látszanak."), + p("""That file could not be read as a picture\.""", "Ezt a fájlt nem sikerült képként beolvasni."), + p("""Could not write to the chosen file\.""", "Nem sikerült írni a kiválasztott fájlba."), + p("""Your TrackBoard account was deleted\. Everything on this phone is still here\.""", "A TrackBoard-fiókod törlődött. A telefonon minden megmaradt."), + p("""Your TrackBoard sign-in expired\. Sign in again\.""", "Lejárt a TrackBoard-bejelentkezésed. Jelentkezz be újra."), + + // ── The rig's link layer ───────────────────────────────────────────── + p("""Bluetooth permission not granted""", "Nincs Bluetooth-engedély"), + p("""No Bluetooth device selected""", "Nincs kiválasztott Bluetooth-eszköz"), + p("""Bluetooth not supported on this device""", "Ez az eszköz nem támogatja a Bluetooth-t"), + p("""Bluetooth connect timed out after (\d+)ms""", "A Bluetooth-kapcsolódás $1 ms után időtúllépéssel leállt"), + p("""Link failed""", "A kapcsolat megszakadt"), + + // ── TrackBoard's own answers ───────────────────────────────────────── + p("""Email or password is incorrect\.""", "Hibás e-mail-cím vagy jelszó."), + p("""The password is incorrect\.""", "Hibás jelszó."), + p("""An account with that email already exists\.""", "Ezzel az e-mail-címmel már van fiók."), + p("""Passwords must be at least 12 characters\.""", "A jelszónak legalább 12 karakterből kell állnia."), + p("""A display name needs at least two visible characters\.""", "A megjelenített névhez legalább két látható karakter kell."), + p("""Too many failed attempts\. Try again in (\d+) minutes\.""", "Túl sok sikertelen próbálkozás. Próbáld újra $1 perc múlva."), + p("""This account no longer exists\.""", "Ez a fiók már nem létezik."), + p("""The refresh token is not valid\.""", "A bejelentkezés már nem érvényes."), + p("""Rate limit exceeded\..*""", "Túl sok kérés. Várj egy kicsit, és próbáld újra."), + p("""Too many requests""", "Túl sok kérés"), + p("""The request could not be completed\. Contact support with the trace identifier\.""", "A kérést nem sikerült teljesíteni. Fordulj a támogatáshoz a nyomkövetési azonosítóval."), + p("""A track needs at least two points\.""", "Egy pályához legalább két pont kell."), + p("""This track already has ranked laps, so its points can no longer change\.""", "Ezen a pályán már vannak rangsorolt körök, ezért a pontjai nem változhatnak."), + p("""Lap number (\d+) appears more than once\.""", "A(z) $1. kör többször szerepel."), + p("""Lap (\d+) lists sector (\d+) more than once\.""", "A(z) $1. körben a(z) $2. szektor többször szerepel."), + p("""Point seq (\d+) appears more than once\.""", "A(z) $1. pont többször szerepel."), + p("""Other drivers have sessions on this track, so it cannot be deleted\.""", "Más pilóták is futottak ezen a pályán, ezért nem törölhető."), + p("""Other drivers have sessions on this track, so it cannot be made private\.""", "Más pilóták is futottak ezen a pályán, ezért nem tehető priváttá."), + p("""An event runs on this track, so it cannot be deleted\. Delete the event first\.""", "Ezen a pályán esemény fut, ezért nem törölhető. Előbb az eseményt töröld."), + p("""This vehicle is used by uploaded sessions and cannot be deleted\.""", "Ehhez az autóhoz feltöltött menetek tartoznak, ezért nem törölhető."), + p("""The vehicle does not belong to this driver\.""", "Az autó nem ehhez a pilótához tartozik."), + p("""Photo storage is not configured on this server\.""", "Ezen a szerveren nincs beállítva fotótár."), + p("""Photo storage refused the upload\. Check that the media bucket exists\.""", "A fotótár visszautasította a feltöltést."), + p("""Photo storage returned an empty answer\.""", "A fotótár üres választ adott."), + p("""That photo was not uploaded for this profile\.""", "Ez a fotó nem ehhez a profilhoz lett feltöltve."), + p("""contentType must be image/jpeg or image/webp\.""", "Csak JPEG vagy WebP kép tölthető fel."), + p("""An event needs a track\.""", "Az eseményhez pálya kell."), + p("""An event can last at most 7 days\.""", "Egy esemény legfeljebb 7 napig tarthat."), + p("""Only the event's host can do that\.""", "Ezt csak az esemény szervezője teheti meg."), + p("""This event has finished, so it can no longer be joined\.""", "Ez az esemény véget ért, ezért már nem lehet csatlakozni."), + p("""Could not find a free join code\.""", "Nem sikerült szabad csatlakozási kódot találni."), + p("""You have reached the limit of (\d+) (.+)\. Delete one before adding another\.""", "Elérted a korlátot ($1 db). Törölj egyet, mielőtt újat adsz hozzá."), + p("""The (.+) field is required\.""", "A(z) $1 mező kitöltése kötelező."), + p("""The (.+) field is not a valid e-mail address\.""", "A(z) $1 mező nem érvényes e-mail-cím."), + ) +} diff --git a/app/src/main/java/com/example/trackpro/online/OnlineServices.kt b/app/src/main/java/com/example/trackpro/online/OnlineServices.kt index 3228362..0170fe7 100644 --- a/app/src/main/java/com/example/trackpro/online/OnlineServices.kt +++ b/app/src/main/java/com/example/trackpro/online/OnlineServices.kt @@ -1,7 +1,15 @@ package com.example.trackpro.online import android.content.Context +import androidx.room.withTransaction +import android.util.Log +import com.example.trackpro.dataClasses.RemoteLink import com.example.trackpro.managerClasses.ESPDatabase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import com.example.trackpro.managerClasses.utilities.PhotoStore /** @@ -31,6 +39,38 @@ class OnlineServices(private val context: Context, private val database: ESPData ProfileRepository(context, accountApi, auth, database, photos) } + /** Same client again: the track-day endpoints. */ + val eventsApi: TrackBoardEventsApi = api as TrackBoardEventsApi + + val events: EventRepository by lazy { + EventRepository( + context = context, + api = eventsApi, + auth = auth, + dao = database.syncDao(), + remoteTrackIdFor = ::remoteTrackIdFor, + transaction = { block -> database.withTransaction { block() } }, + ) + } + + /** + * One upload at a time: the background sync and the per-lap live push share links, and two + * writers at once could each post the same session under a different id. + */ + val syncLock = Mutex() + + /** + * Called after every completed lap. Sends the running session at once when it belongs to a + * joined event, so the event board moves within seconds of the lap. + */ + fun onLapCompleted(sessionId: Long, scope: CoroutineScope) { + if (!auth.isSignedIn || events.joined.value.isEmpty()) return + scope.launch(Dispatchers.IO) { + runCatching { syncLock.withLock { syncEngine().pushLive(sessionId) } } + .onFailure { Log.w("TrackBoard", "Live event upload failed; the end-of-session sync retries", it) } + } + } + fun syncEngine(): SyncEngine = SyncEngine( api = api, auth = auth, @@ -39,6 +79,8 @@ class OnlineServices(private val context: Context, private val database: ESPData sharingEnabled = { settings.sharingEnabled.value }, appVersion = appVersion, garage = GarageSync(accountApi, api, auth, database.syncDao(), photos), + transaction = { block -> database.withTransaction { block() } }, + events = { events.joined.value }, ) /** The server id a local track's leaderboard lives under, or null if it has none yet. */ @@ -46,7 +88,8 @@ class OnlineServices(private val context: Context, private val database: ESPData val dao = database.syncDao() val track = dao.getTrack(localTrackId) ?: return null premadeTracks.remoteIdFor(track, dao.getTrackPoints(localTrackId))?.let { return it } - return dao.getLink(com.example.trackpro.dataClasses.RemoteLink.KIND_TRACK, localTrackId) + dao.getLink(RemoteLink.KIND_SHARED_TRACK, localTrackId)?.let { return it.remoteId } + return dao.getLink(RemoteLink.KIND_TRACK, localTrackId) ?.takeIf { it.lastError == null } ?.remoteId } diff --git a/app/src/main/java/com/example/trackpro/online/OnlineSettings.kt b/app/src/main/java/com/example/trackpro/online/OnlineSettings.kt index 12f43b9..803ee20 100644 --- a/app/src/main/java/com/example/trackpro/online/OnlineSettings.kt +++ b/app/src/main/java/com/example/trackpro/online/OnlineSettings.kt @@ -1,5 +1,7 @@ package com.example.trackpro.online +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import android.content.Context import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -13,7 +15,7 @@ data class SyncReport( val uploaded: Int = 0, val unchanged: Int = 0, val withdrawn: Int = 0, - /** Cars and photos restored from the account onto this phone. */ + /** Cars, photos, tracks and sessions restored from the account onto this phone. */ val downloaded: Int = 0, val failed: Int = 0, /** The first problem, phrased for the driver. */ @@ -23,21 +25,21 @@ data class SyncReport( ) /** One line for the driver about the last sync, e.g. "3 sent · 1 restored · 14:02". */ +@androidx.compose.runtime.Composable fun describe(report: SyncReport?): String { - if (report == null) return "Not synced yet" + if (report == null) return stringResource(R.string.sync_not_yet) val at = java.text.DateFormat.getDateTimeInstance(java.text.DateFormat.SHORT, java.text.DateFormat.SHORT) .format(java.util.Date(report.finishedAt)) - if (report.offline) return "Offline at $at · will retry" - - val parts = buildList { - if (report.uploaded > 0) add("${report.uploaded} sent") - if (report.downloaded > 0) add("${report.downloaded} restored") - if (report.withdrawn > 0) add("${report.withdrawn} withdrawn") - if (report.unchanged > 0) add("${report.unchanged} up to date") - if (report.failed > 0) add("${report.failed} failed") - } - val summary = parts.ifEmpty { listOf("Everything up to date") }.joinToString(" · ") - return listOfNotNull("$summary · $at", report.problem).joinToString("\n") + if (report.offline) return stringResource(R.string.sync_offline_at, at) + + val parts = mutableListOf() + if (report.uploaded > 0) parts += stringResource(R.string.sync_sent, report.uploaded) + if (report.downloaded > 0) parts += stringResource(R.string.sync_restored, report.downloaded) + if (report.withdrawn > 0) parts += stringResource(R.string.sync_withdrawn, report.withdrawn) + if (report.unchanged > 0) parts += stringResource(R.string.sync_up_to_date_n, report.unchanged) + if (report.failed > 0) parts += stringResource(R.string.sync_failed_n, report.failed) + val summary = parts.ifEmpty { listOf(stringResource(R.string.sync_all_up_to_date)) }.joinToString(" · ") + return listOfNotNull("$summary · $at", MessageText.localize(report.problem)).joinToString("\n") } /** diff --git a/app/src/main/java/com/example/trackpro/online/PayloadMapper.kt b/app/src/main/java/com/example/trackpro/online/PayloadMapper.kt index 81b3530..0b6083c 100644 --- a/app/src/main/java/com/example/trackpro/online/PayloadMapper.kt +++ b/app/src/main/java/com/example/trackpro/online/PayloadMapper.kt @@ -126,6 +126,8 @@ object PayloadMapper { remoteTrackId: String, remoteVehicleId: String?, appVersion: String?, + /** Ranked for the public leaderboard; Private when it goes up only for an event board. */ + visibility: ApiSessionVisibility = ApiSessionVisibility.Ranked, ): SessionWrite? { val source = gpsSource(session.gpsSource) ?: return null val sectorsByLap = sectors.groupBy { it.lapid } @@ -159,7 +161,7 @@ object PayloadMapper { vehicleId = remoteVehicleId, trackId = remoteTrackId, gpsSource = source, - visibility = ApiSessionVisibility.Ranked, + visibility = visibility, voided = session.voided, weather = weather(session), appVersion = appVersion?.take(40), diff --git a/app/src/main/java/com/example/trackpro/online/ProfileRepository.kt b/app/src/main/java/com/example/trackpro/online/ProfileRepository.kt index 30b22a9..9f610df 100644 --- a/app/src/main/java/com/example/trackpro/online/ProfileRepository.kt +++ b/app/src/main/java/com/example/trackpro/online/ProfileRepository.kt @@ -144,9 +144,11 @@ class ProfileRepository( /** * Deletes the account on the server, then everything on this phone that pointed at it. * The phone's own records — cars, tracks, sessions — stay: they were never the account's. + * The server wants the account [password] as well as the token. A wrong one throws an + * [ApiException] and changes nothing, here or there. */ - suspend fun deleteAccount() { - auth.authorized { api.deleteAccount(it) } + suspend fun deleteAccount(password: String) { + auth.authorized { api.deleteAccount(it, password) } database.syncDao().deleteAllLinks() _account.value?.avatarFile?.let(photos::delete) clear() diff --git a/app/src/main/java/com/example/trackpro/online/SyncEngine.kt b/app/src/main/java/com/example/trackpro/online/SyncEngine.kt index 37121c8..1260a34 100644 --- a/app/src/main/java/com/example/trackpro/online/SyncEngine.kt +++ b/app/src/main/java/com/example/trackpro/online/SyncEngine.kt @@ -16,7 +16,8 @@ import java.util.UUID class SyncProblem(message: String) : Exception(message) /** - * Mirrors the driver's leaderboard-eligible sessions onto TrackBoard, and nothing else. + * Mirrors the driver's leaderboard-eligible sessions onto TrackBoard, and brings the account's + * tracks and sessions back down onto this phone (see [AccountRestore]). * * What goes up is decided fresh on every run from local data, so every path — first sync, * a voided session, an unpublished track, sharing switched off, a deleted session — is the @@ -24,6 +25,9 @@ class SyncProblem(message: String) : Exception(message) * * - A session is posted when sharing is on, it recorded its GPS source, it has a timed lap, * and its track is either premade or published by the driver. + * - A session driven during a joined event, on the event's track, is posted too - joining is + * the consent - as Private when sharing is off, so it reaches the event board but not the + * public leaderboard. While it is still running, [pushLive] sends it after every lap. * - Premade tracks are linked to the one shared server track every install derives the same * id for; the first driver to post on one creates it. * - Anything online that no longer qualifies is withdrawn. @@ -42,15 +46,29 @@ class SyncEngine( private val clock: () -> Long = System::currentTimeMillis, /** The garage backup. Null in tests that only exercise leaderboard sync. */ private val garage: GarageSync? = null, + /** Runs a block atomically: Room's withTransaction in the app, a plain call in tests. */ + transaction: suspend (suspend () -> Unit) -> Unit = { it() }, + /** Events the driver joined. Their sessions go up even with sharing off. */ + private val events: () -> List = { emptyList() }, ) { + private val restore = AccountRestore(api, auth, dao, premade, appVersion, transaction, clock) + private class TrackInfo( val track: TrackMainData, val points: List, - /** Non-null when this is an unmodified premade track. */ + /** + * Non-null when the track already exists on the server and is not this driver's to + * upload: an unmodified premade track, or a published one downloaded for an event. + */ val premadeId: String?, ) - private class Plan(val session: SessionData, val track: TrackInfo, val laps: List) + private class Plan( + val session: SessionData, + val track: TrackInfo, + val laps: List, + val visibility: ApiSessionVisibility, + ) private class Tally { var uploaded = 0 @@ -70,33 +88,50 @@ class SyncEngine( val tally = Tally() val garageTally = GarageTally() + val restoreTally = RestoreTally() try { - val published = dao.getPublishedTrackIds().toSet() + restore.dropRestoredDuplicates() + + var published = dao.getPublishedTrackIds().toSet() val tracks = mutableMapOf() suspend fun track(id: Long) = tracks.getOrPut(id) { loadTrack(id) } - val plans = if (sharingEnabled()) planSessions(published, ::track) else emptyList() - val wanted = plans.mapTo(mutableSetOf()) { it.session.id } + val sharing = sharingEnabled() + val wanted = planSessions(published, ::track, sharing).mapTo(mutableSetOf()) { it.session.id } + // A session still recording may have gone up live for an event. It is not finished, + // so it is not planned, but it must not be withdrawn from under the event board. + val running = dao.getRunningSessionIds().toSet() // Withdrawals first: a session leaving the leaderboard should not queue behind uploads. for (link in dao.getLinks(KIND_SESSION)) { - if (link.localId !in wanted) attempt(tally) { withdrawSession(link, tally) } + if (link.localId !in wanted && link.localId !in running) attempt(tally) { withdrawSession(link, tally) } } // After withdrawals, so a car deleted together with its sessions is no longer // held on the account by them; before uploads, so sessions find their car linked. garage?.run(garageTally) + // After the garage, so restored sessions find their cars. Anything restored is + // linked as already online, so the upload steps below leave it alone. + restore.restoreTracks(restoreTally) + if (sharing) restore.restoreSessions(restoreTally) + // Restored tracks may be published; read again, or the step below would make them private. + published = dao.getPublishedTrackIds().toSet() + syncUserTracks(published, ::track, tally) + // Planned again rather than reusing the plans above: the restore changes sessions + // too - it gives a session brought down without its car the car back - and an + // upload from the earlier snapshot would post the session without it. + val plans = planSessions(published, ::track, sharing) for (plan in plans) attempt(tally) { uploadSession(plan, tally) } } catch (e: NetworkException) { - return tally.report(garageTally, offline = true, problem = e.message) + return tally.report(garageTally, restoreTally, offline = true, problem = e.message) } catch (e: ApiException) { // Only a 401 escapes attempt(): the refresh failed and the driver is now signed out. - return tally.report(garageTally, problem = e.message) + return tally.report(garageTally, restoreTally, problem = e.message) } - return tally.report(garageTally) + return tally.report(garageTally, restoreTally) } // ── Planning ── @@ -104,21 +139,66 @@ class SyncEngine( private suspend fun planSessions( published: Set, track: suspend (Long) -> TrackInfo?, + sharing: Boolean, ): List = dao.getFinishedTrackSessions().mapNotNull { session -> - if (PayloadMapper.gpsSource(session.gpsSource) == null) return@mapNotNull null - val info = session.trackId?.let { track(it) } ?: return@mapNotNull null - if (info.premadeId == null && info.track.trackId !in published) return@mapNotNull null + plan(session, published, track, sharing) + } + + /** Whether [session] goes up at all, and how: Ranked for the leaderboard, Private for an event only. */ + private suspend fun plan( + session: SessionData, + published: Set, + track: suspend (Long) -> TrackInfo?, + sharing: Boolean, + ): Plan? { + if (PayloadMapper.gpsSource(session.gpsSource) == null) return null + val info = session.trackId?.let { track(it) } ?: return null + + val ranked = sharing && (info.premadeId != null || info.track.trackId in published) + val inEvent = remoteTrackId(info, published)?.let { remote -> + events().any { it.trackId == remote && it.covers(session.startTime) } + } == true + if (!ranked && !inEvent) return null val laps = dao.getLaps(session.id) // Same rules as the real upload, so "wanted" and "sendable" can never disagree. val sendable = PayloadMapper.session(session, laps, emptyList(), "planning", null, null) != null - if (sendable) Plan(session, info, laps) else null + if (!sendable) return null + val visibility = if (ranked) ApiSessionVisibility.Ranked else ApiSessionVisibility.Private + return Plan(session, info, laps, visibility) + } + + /** The track's server id when it has one: shared or premade, or the driver's own once published and sent. */ + private suspend fun remoteTrackId(info: TrackInfo, published: Set): String? = + info.premadeId + ?: if (info.track.trackId in published) dao.getLink(KIND_TRACK, info.track.trackId)?.remoteId else null + + /** + * Sends a session that is still recording, straight after a lap, when it falls in a joined + * event. Uses the same links as [run], so the full sync afterwards sees it as already up. + * Returns false when it is not an event session; a network problem throws, and the full + * sync at the end of the session catches up. + */ + suspend fun pushLive(sessionId: Long): Boolean { + if (!auth.isSignedIn) return false + val session = dao.getSession(sessionId) ?: return false + val published = dao.getPublishedTrackIds().toSet() + val info = session.trackId?.let { loadTrack(it) } ?: return false + val remote = remoteTrackId(info, published) ?: return false + if (events().none { it.trackId == remote && it.covers(session.startTime) }) return false + + val plan = plan(session, published, { id -> if (id == info.track.trackId) info else loadTrack(id) }, sharingEnabled()) + ?: return false + uploadSession(plan, Tally()) + return true } private suspend fun loadTrack(trackId: Long): TrackInfo? { val track = dao.getTrack(trackId) ?: return null val points = dao.getTrackPoints(trackId) - return TrackInfo(track, points, premade.remoteIdFor(track, points)) + val shared = premade.remoteIdFor(track, points) + ?: dao.getLink(RemoteLink.KIND_SHARED_TRACK, trackId)?.remoteId + return TrackInfo(track, points, shared) } // ── Sessions ── @@ -134,8 +214,9 @@ class SyncEngine( val vehicleRemoteId = plan.session.vehicleId?.let { ensureVehicle(it) } val sectors = dao.getSectors(plan.laps.map { it.id }) - val body = PayloadMapper.session(plan.session, plan.laps, sectors, trackRemoteId, vehicleRemoteId, appVersion) - ?: return + val body = PayloadMapper.session( + plan.session, plan.laps, sectors, trackRemoteId, vehicleRemoteId, appVersion, plan.visibility + ) ?: return val hash = PayloadMapper.hash(body) val link = dao.getLink(KIND_SESSION, plan.session.id) @@ -171,16 +252,19 @@ class SyncEngine( } for (link in dao.getLinks(KIND_TRACK)) { - if (link.localId in published) continue + if (link.localId in published || link.uploadedHash == GarageSync.DELETED_HERE) continue val info = track(link.localId) attempt(tally) { if (info == null) { // Deleted on the phone. If other drivers have laps on it the server keeps - // it for them, which is correct; either way it is no longer ours to track. + // it for them, which is correct - but then the link stays, marked, or the + // next sync would restore the track the driver just deleted. try { auth.authorized { token -> api.deleteTrack(token, link.remoteId) } } catch (e: ApiException) { if (e.status == 401 || e.code != "TrackInUse") throw e + dao.putLink(link.copy(uploadedHash = GarageSync.DELETED_HERE, lastError = null)) + return@attempt } dao.deleteLink(KIND_TRACK, link.localId) } else { @@ -227,6 +311,9 @@ class SyncEngine( auth.authorized { token -> api.putTrack(token, remoteId, body) } } catch (e: ApiException) { if (e.status == 401) throw e + // The account is full. The server's own message says so and what to do about + // it, which "isn't available right now" below would hide. + if (e.code == "QuotaExceeded") throw SyncProblem(e.message ?: "This account has reached its limit.") // 403: another driver created it between our check and our write. Theirs is // identical by construction, so linking to it is exactly right — if we can see it. val createdByAnother = e.status == 403 && @@ -300,15 +387,16 @@ class SyncEngine( private fun Tally.report( garage: GarageTally, + restored: RestoreTally, offline: Boolean = false, - problem: String? = this.problem ?: garage.problem, + problem: String? = this.problem ?: garage.problem ?: restored.problem, ) = SyncReport( finishedAt = clock(), uploaded = uploaded + garage.uploaded, unchanged = unchanged, withdrawn = withdrawn, - downloaded = garage.downloaded, - failed = failed + garage.failed, + downloaded = garage.downloaded + restored.downloaded, + failed = failed + garage.failed + restored.failed, problem = problem, offline = offline, ) diff --git a/app/src/main/java/com/example/trackpro/online/SyncWorker.kt b/app/src/main/java/com/example/trackpro/online/SyncWorker.kt index f7f3df6..61f4f40 100644 --- a/app/src/main/java/com/example/trackpro/online/SyncWorker.kt +++ b/app/src/main/java/com/example/trackpro/online/SyncWorker.kt @@ -15,6 +15,7 @@ import androidx.work.WorkerParameters import com.example.trackpro.TrackProApp import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.withLock import java.util.concurrent.TimeUnit /** @@ -27,12 +28,16 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c override suspend fun doWork(): Result { val online = (applicationContext as TrackProApp).online + // Keeps the phone's list of joined events current, so their sessions are planned + // below. Best effort: offline, the list the phone already has stands. + runCatching { online.events.refresh() } + if (online.settings.serverUrl.value.isBlank()) { online.settings.recordSync(SyncReport(System.currentTimeMillis(), problem = "No TrackBoard server is set.")) return Result.success() } - val report = online.syncEngine().run() + val report = online.syncLock.withLock { online.syncEngine().run() } online.settings.recordSync(report) // Offline is the one outcome worth retrying: every other problem needs a change on diff --git a/app/src/main/java/com/example/trackpro/online/TrackBoardApi.kt b/app/src/main/java/com/example/trackpro/online/TrackBoardApi.kt index d3a2e51..02b144d 100644 --- a/app/src/main/java/com/example/trackpro/online/TrackBoardApi.kt +++ b/app/src/main/java/com/example/trackpro/online/TrackBoardApi.kt @@ -3,6 +3,7 @@ package com.example.trackpro.online import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.json.Json import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull @@ -52,6 +53,21 @@ interface TrackBoardApi { /** Null when the track does not exist or is not visible to this caller. */ suspend fun getTrack(accessToken: String?, id: String): TrackSummary? + /** Like [getTrack], with the geometry. */ + suspend fun getTrackDetail(accessToken: String, id: String): TrackDetail? + + /** One page of the caller's own tracks, published and private, 1-based. */ + suspend fun listMyTracks(accessToken: String, page: Int, pageSize: Int = 100): TrackPage + + /** One page of the caller's sessions, newest first, 1-based. */ + suspend fun listSessions(accessToken: String, page: Int, pageSize: Int = 100): SessionPage + + /** Null when the session no longer exists. */ + suspend fun getSession(accessToken: String, id: String): SessionDetail? + + /** Null when the car no longer exists or is not the caller's. */ + suspend fun getVehicle(accessToken: String, id: String): VehicleResponse? + /** Null when the track does not exist or is not published. Public: the token is optional. */ suspend fun getLeaderboard(accessToken: String?, trackId: String, limit: Int): Leaderboard? } @@ -67,7 +83,8 @@ interface TrackBoardAccountApi { suspend fun getStats(accessToken: String): ProfileStats /** The raw export document, exactly as the server wrote it. */ suspend fun exportAccount(accessToken: String): String - suspend fun deleteAccount(accessToken: String) + /** Needs the account [password] as well as the token. A wrong one is a 403, not a 401. */ + suspend fun deleteAccount(accessToken: String, password: String) /** One page of the caller's vehicles, 1-based. */ suspend fun listVehicles(accessToken: String, page: Int, pageSize: Int = 100): VehiclePage @@ -83,11 +100,35 @@ interface TrackBoardAccountApi { suspend fun download(url: String): ByteArray? } +/** + * Track days: joining with a code and reading an event's live board. Its own interface, like + * [TrackBoardAccountApi], so the sync fakes in tests do not have to grow with it. + */ +interface TrackBoardEventsApi { + /** Events the caller hosts or joined, newest first. */ + suspend fun myEvents(accessToken: String): List + + /** Throws a 404 [ApiException] when no event has the code. */ + suspend fun eventByCode(accessToken: String, code: String): EventDetail + + /** Joins, or changes group when already joined. Throws a 409 when the event has finished. */ + suspend fun joinEvent(accessToken: String, code: String, groupId: String?): EventDetail + + /** Succeeds when already gone. */ + suspend fun leaveEvent(accessToken: String, eventId: String, userId: String) + + /** Public: the token only matters for a stale one, which the server answers with 401. */ + suspend fun eventBoard(accessToken: String?, eventId: String): EventBoard? + + /** A published track with its geometry, for driving an event on it. Public. */ + suspend fun publishedTrack(id: String): TrackDetail? +} + class OkHttpTrackBoardApi( /** Read on every call, so a changed server address in Settings applies immediately. */ private val baseUrl: () -> String?, private val client: OkHttpClient = defaultClient(), -) : TrackBoardApi, TrackBoardAccountApi { +) : TrackBoardApi, TrackBoardAccountApi, TrackBoardEventsApi { override suspend fun register(request: RegisterRequest): AuthResponse = send("POST", "auth/register", null, request, RegisterRequest.serializer(), AuthResponse.serializer()) @@ -131,6 +172,33 @@ class OkHttpTrackBoardApi( json.decodeFromString(TrackSummary.serializer(), response.bodyText()) } + override suspend fun getTrackDetail(accessToken: String, id: String): TrackDetail? = + call("GET", "tracks/$id", accessToken, null).use { response -> + if (response.code == 404) return@use null + response.requireSuccess() + json.decodeFromString(TrackDetail.serializer(), response.bodyText()) + } + + override suspend fun listMyTracks(accessToken: String, page: Int, pageSize: Int): TrackPage = + get("tracks?mine=true&page=$page&pageSize=$pageSize", accessToken, TrackPage.serializer()) + + override suspend fun listSessions(accessToken: String, page: Int, pageSize: Int): SessionPage = + get("sessions?page=$page&pageSize=$pageSize", accessToken, SessionPage.serializer()) + + override suspend fun getSession(accessToken: String, id: String): SessionDetail? = + call("GET", "sessions/$id", accessToken, null).use { response -> + if (response.code == 404) return@use null + response.requireSuccess() + json.decodeFromString(SessionDetail.serializer(), response.bodyText()) + } + + override suspend fun getVehicle(accessToken: String, id: String): VehicleResponse? = + call("GET", "vehicles/$id", accessToken, null).use { response -> + if (response.code == 404 || response.code == 403) return@use null + response.requireSuccess() + json.decodeFromString(VehicleResponse.serializer(), response.bodyText()) + } + override suspend fun getLeaderboard(accessToken: String?, trackId: String, limit: Int): Leaderboard? = call("GET", "tracks/$trackId/leaderboard?limit=$limit", accessToken, null).use { response -> if (response.code == 404) return@use null @@ -155,8 +223,9 @@ class OkHttpTrackBoardApi( response.bodyText() } - override suspend fun deleteAccount(accessToken: String) { - call("DELETE", "me", accessToken, null).use { it.requireSuccess() } + override suspend fun deleteAccount(accessToken: String, password: String) { + call("DELETE", "me", accessToken, encode(DeleteAccountRequest.serializer(), DeleteAccountRequest(password))) + .use { it.requireSuccess() } } override suspend fun listVehicles(accessToken: String, page: Int, pageSize: Int): VehiclePage = @@ -214,6 +283,37 @@ class OkHttpTrackBoardApi( } } + // ── Events ── + + override suspend fun myEvents(accessToken: String): List = + get("events", accessToken, ListSerializer(EventSummary.serializer())) + + override suspend fun eventByCode(accessToken: String, code: String): EventDetail = + get("events/code/${code.filter(Char::isLetterOrDigit)}", accessToken, EventDetail.serializer()) + + override suspend fun joinEvent(accessToken: String, code: String, groupId: String?): EventDetail = + send("POST", "events/join", accessToken, JoinEventRequest(code, groupId), JoinEventRequest.serializer(), EventDetail.serializer()) + + override suspend fun leaveEvent(accessToken: String, eventId: String, userId: String) { + call("DELETE", "events/$eventId/entries/$userId", accessToken, null).use { response -> + if (response.code != 404) response.requireSuccess() + } + } + + override suspend fun eventBoard(accessToken: String?, eventId: String): EventBoard? = + call("GET", "events/$eventId/board", accessToken, null).use { response -> + if (response.code == 404) return@use null + response.requireSuccess() + json.decodeFromString(EventBoard.serializer(), response.bodyText()) + } + + override suspend fun publishedTrack(id: String): TrackDetail? = + call("GET", "tracks/$id", null, null).use { response -> + if (response.code == 404) return@use null + response.requireSuccess() + json.decodeFromString(TrackDetail.serializer(), response.bodyText()) + } + // ── Plumbing ── private suspend fun get(path: String, accessToken: String, serializer: KSerializer): Res = diff --git a/app/src/main/java/com/example/trackpro/online/VehicleSyncStatus.kt b/app/src/main/java/com/example/trackpro/online/VehicleSyncStatus.kt index 34e7261..dd6dd49 100644 --- a/app/src/main/java/com/example/trackpro/online/VehicleSyncStatus.kt +++ b/app/src/main/java/com/example/trackpro/online/VehicleSyncStatus.kt @@ -1,19 +1,20 @@ package com.example.trackpro.online +import com.example.trackpro.R import com.example.trackpro.dataClasses.RemoteLink import com.example.trackpro.dataClasses.VehicleInformationData /** Where a car stands against the driver's account, as the garage shows it. */ -enum class VehicleSyncStatus(val label: String) { +enum class VehicleSyncStatus(@androidx.annotation.StringRes val label: Int) { /** Signed out: the car exists on this phone, and that is all there is to say. */ - OnThisPhone("On this phone"), + OnThisPhone(R.string.sync_on_phone), /** New or edited here, waiting for the next sync. */ - Pending("Waiting to back up"), - Synced("Backed up"), + Pending(R.string.sync_pending), + Synced(R.string.sync_synced), /** The account refused it; [RemoteLink.lastError] says why. */ - Failed("Backup failed"), + Failed(R.string.sync_failed), /** Removed from the account on another phone and kept here. */ - LocalOnly("Only on this phone"), + LocalOnly(R.string.sync_local_only), } fun vehicleSyncStatus( diff --git a/app/src/main/java/com/example/trackpro/online/ui/EventBoardScreen.kt b/app/src/main/java/com/example/trackpro/online/ui/EventBoardScreen.kt new file mode 100644 index 0000000..cbf773d --- /dev/null +++ b/app/src/main/java/com/example/trackpro/online/ui/EventBoardScreen.kt @@ -0,0 +1,401 @@ +package com.example.trackpro.online.ui + +import com.example.trackpro.online.MessageText +import com.example.trackpro.components.pluralResource +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.trackpro.TrackProApp +import com.example.trackpro.components.DataGate +import com.example.trackpro.components.Haptic +import com.example.trackpro.components.PaddockCard +import com.example.trackpro.components.PrimaryButton +import com.example.trackpro.components.ScreenScaffold +import com.example.trackpro.components.SectionSwitch +import com.example.trackpro.components.isScrolledUnderChrome +import com.example.trackpro.components.paddockCard +import com.example.trackpro.components.pressable +import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.managerClasses.utilities.toLapDeltaString +import com.example.trackpro.managerClasses.utilities.toLapTimeString +import com.example.trackpro.models.LoadState +import com.example.trackpro.online.AccountState +import com.example.trackpro.online.ApiEventStatus +import com.example.trackpro.online.ApiException +import com.example.trackpro.online.EventBoard +import com.example.trackpro.online.EventBoardEntry +import com.example.trackpro.online.NetworkException +import com.example.trackpro.online.SyncProblem +import com.example.trackpro.theme.Spacing +import com.example.trackpro.theme.marking +import com.example.trackpro.theme.markingDim +import com.example.trackpro.theme.TrackProShapes +import com.example.trackpro.theme.TrackProType +import com.example.trackpro.theme.atSize +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * One event's live board: every joined driver by best lap, overall or within a run group. + * Refreshes every few seconds while the event is live and the screen is open; a lap the + * driver completes on the event's track posts itself and shows here moments later. + */ +@Composable +fun EventBoardScreen(eventId: String, onBack: () -> Unit) { + val app = LocalContext.current.applicationContext as TrackProApp + val online = app.online + val scope = rememberCoroutineScope() + val myId = (online.auth.state.value as? AccountState.SignedIn)?.userId + + var state by remember { mutableStateOf(LoadState.Loading) } + var board by remember { mutableStateOf(null) } + var group by remember { mutableStateOf(null) } + var reload by remember { mutableIntStateOf(0) } + var localTrack by remember { mutableStateOf(null) } + var trackBusy by remember { mutableStateOf(false) } + var notice by remember { mutableStateOf(null) } + var confirmLeave by remember { mutableStateOf(false) } + + // Polls while the screen is open; leaving it cancels the loop with the composition. + LaunchedEffect(eventId, reload) { + if (board == null) state = LoadState.Loading + while (true) { + try { + val next = online.events.board(eventId) + if (next == null) { + state = LoadState.Failed(app.getString(R.string.board_gone)) + return@LaunchedEffect + } + board = next + state = LoadState.Ready + if (localTrack == null) localTrack = online.events.localTrackFor(next.trackId) + } catch (e: NetworkException) { + // Keep the last board up through a signal drop in the paddock. + if (board == null) state = LoadState.Failed(MessageText.localize(e.message) ?: app.getString(R.string.online_unreachable)) + } catch (e: ApiException) { + if (board == null) state = LoadState.Failed(MessageText.localize(e.message) ?: app.getString(R.string.online_refused)) + } + delay(if (board?.status == ApiEventStatus.Live) LIVE_REFRESH_MS else IDLE_REFRESH_MS) + } + } + + val b = board + val entries = b?.entries.orEmpty().filter { group == null || it.groupId == group } + val mine = b?.entries?.firstOrNull { it.userId == myId } + val joined = mine != null + val listState = rememberLazyListState() + val scrolled by listState.isScrolledUnderChrome() + val groups = b?.groups.orEmpty() + + ScreenScaffold( + title = b?.name ?: stringResource(R.string.board_event), + subtitle = b?.let { "${it.trackName} · ${window(it.startsAt, it.endsAt)}" }, + onBack = onBack, + trailing = { b?.let { StatusPill(it.status) } }, + contentScrolled = scrolled, + header = if (groups.isNotEmpty()) { + { + SectionSwitch( + options = listOf>(null to stringResource(R.string.board_overall)) + groups.map { it.id to it.name }, + selected = group, + onSelect = { group = it } + ) + } + } else null + ) { padding -> + DataGate( + state = state, + items = entries, + emptyMessage = if (group == null) stringResource(R.string.board_nobody) else stringResource(R.string.board_nobody_group), + emptyHint = stringResource(R.string.board_empty_hint), + loadingLabel = stringResource(R.string.board_fetching), + onRetry = { reload++ }, + ) { rows -> + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = padding.calculateTopPadding() + Spacing.sm, bottom = Spacing.xxl) + ) { + if (b != null && joined && localTrack == null) { + item(key = "track") { + GetTrackCard( + trackName = b.trackName, + busy = trackBusy, + onGet = { + trackBusy = true + scope.launch { + notice = try { + localTrack = online.events.downloadTrack(b.trackId) + app.getString(R.string.board_track_ready, b.trackName) + } catch (e: SyncProblem) { + MessageText.localize(e.message) + } catch (e: NetworkException) { + app.getString(R.string.board_track_needs_connection) + } catch (e: ApiException) { + MessageText.localize(e.message) + } + trackBusy = false + } + } + ) + } + } + + if (mine != null && mine.rank != null) { + item(key = "me") { MyPlaceCard(mine, fieldSize = b?.entries?.count { it.rank != null } ?: 0, inGroup = group != null) } + } + + notice?.let { + item(key = "notice") { + Text(it, style = TrackProType.body, color = TrackProTheme.colors.markingDim, modifier = Modifier.padding(Spacing.gutter)) + } + } + + item(key = "board-top") { Spacer(Modifier.height(Spacing.sm)) } + items(rows, key = { it.userId }) { entry -> + BoardRow(entry, isMe = entry.userId == myId, inGroup = group != null, groupName = groups.firstOrNull { it.id == entry.groupId }?.name) + } + + item(key = "foot") { + Text( + stringResource(R.string.board_footer), + style = TrackProType.body, + color = TrackProTheme.colors.markingDim, + modifier = Modifier.padding(horizontal = Spacing.gutter, vertical = Spacing.lg) + ) + } + + if (joined) { + item(key = "leave") { + Box(modifier = Modifier.padding(horizontal = Spacing.gutter)) { + if (confirmLeave) { + PrimaryButton( + text = stringResource(R.string.board_leave_confirm), + accent = TrackProTheme.colors.danger, + contentColor = TrackProTheme.colors.onAccent, + haptic = Haptic.Reject, + modifier = Modifier.fillMaxWidth(), + onClick = { + scope.launch { + try { + online.events.leave(eventId) + onBack() + } catch (e: Exception) { + notice = MessageText.localize(e.message) ?: app.getString(R.string.board_leave_failed) + confirmLeave = false + } + } + } + ) + } else { + Text( + stringResource(R.string.board_leave), + style = TrackProType.titleMedium, + color = TrackProTheme.colors.danger, + modifier = Modifier + .pressable(onClick = { confirmLeave = true }, scale = 0.96f) + .heightIn(min = 48.dp) + .padding(vertical = Spacing.md) + ) + } + } + } + } + } + } + } +} + +/** The driver's own standing, lifted out of the list: the first thing they look for. */ +@Composable +private fun MyPlaceCard(me: EventBoardEntry, fieldSize: Int, inGroup: Boolean) { + val rank = if (inGroup) me.groupRank else me.rank + val gap = if (inGroup) me.gapToGroupLeaderMs else me.gapToLeaderMs + Box(modifier = Modifier.padding(horizontal = Spacing.gutter, vertical = Spacing.sm)) { + PaddockCard(padding = Spacing.xl) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "P${rank ?: "–"}", + style = TrackProType.displayNumeric.atSize(44.sp), + color = if (rank == 1) TrackProTheme.colors.accent else TrackProTheme.colors.marking + ) + Spacer(Modifier.width(Spacing.lg)) + Column(modifier = Modifier.weight(1f)) { + Text( + if (rank == 1) stringResource(R.string.board_fastest) else stringResource(R.string.board_you_of, fieldSize), + style = TrackProType.label, + color = TrackProTheme.colors.markingDim + ) + Text(me.bestLapMs?.toLong()?.toLapTimeString() ?: "", style = TrackProType.statValue, color = TrackProTheme.colors.marking) + Text( + listOfNotNull( + gap?.takeIf { it > 0 }?.let { stringResource(R.string.board_to_p1, it.toLong().toLapDeltaString()) }, + pluralResource(R.plurals.count_laps, me.lapCount), + ).joinToString(" · "), + style = TrackProType.body, + color = TrackProTheme.colors.markingDim + ) + } + if (me.onTrack) { + Row(verticalAlignment = Alignment.CenterVertically) { + LiveDot() + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.board_on_track), style = TrackProType.label.copy(fontWeight = FontWeight.SemiBold), color = TrackProTheme.colors.deltaGood) + } + } + } + } + } +} + +@Composable +private fun BoardRow(entry: EventBoardEntry, isMe: Boolean, inGroup: Boolean, groupName: String?) { + val rank = if (inGroup) entry.groupRank else entry.rank + val gap = if (inGroup) entry.gapToGroupLeaderMs else entry.gapToLeaderMs + // A new best lap lands with a brief orange wash, then settles. + var previousBest by remember { mutableStateOf(entry.bestLapMs) } + var fresh by remember { mutableStateOf(false) } + LaunchedEffect(entry.bestLapMs) { + if (previousBest != null && entry.bestLapMs != previousBest) { + fresh = true + delay(2_500) + fresh = false + } + previousBest = entry.bestLapMs + } + val wash by animateColorAsState( + when { + fresh -> TrackProTheme.colors.accent.copy(alpha = 0.18f) + isMe -> TrackProTheme.colors.accent.copy(alpha = 0.08f) + else -> TrackProTheme.colors.accent.copy(alpha = 0f) + }, + animationSpec = tween(600), + label = "rowWash" + ) + + Column(modifier = Modifier.padding(horizontal = Spacing.gutter)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(TrackProShapes.chip) + .background(wash) + .padding(horizontal = Spacing.md, vertical = Spacing.md), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(Spacing.md) + ) { + Text( + rank?.toString() ?: "–", + style = TrackProType.titleLarge, + color = if (rank == 1) TrackProTheme.colors.accent else TrackProTheme.colors.marking, + modifier = Modifier.width(28.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + if (isMe) stringResource(R.string.board_you_suffix, entry.displayName) else entry.displayName, + style = TrackProType.titleMedium, + color = TrackProTheme.colors.marking, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + if (entry.onTrack) { + Spacer(Modifier.width(Spacing.sm)) + LiveDot() + } + } + Text( + listOfNotNull( + if (!inGroup) groupName else null, + entry.vehicle?.let { "${it.manufacturer} ${it.model}" }, + pluralResource(R.plurals.count_laps, entry.lapCount), + ).joinToString(" · "), + style = TrackProType.body, + color = TrackProTheme.colors.markingDim, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Column(horizontalAlignment = Alignment.End) { + Text( + entry.bestLapMs?.toLong()?.toLapTimeString() ?: stringResource(R.string.board_no_lap), + style = TrackProType.titleLarge, + color = if (rank == 1) TrackProTheme.colors.accent else if (entry.bestLapMs == null) TrackProTheme.colors.textFaint else TrackProTheme.colors.marking + ) + val sub = when { + entry.lastLapIsBest && entry.lastLapMs != null -> stringResource(R.string.board_last_lap_pb) + gap != null && gap > 0 -> gap.toLong().toLapDeltaString() + else -> null + } + if (sub != null) { + Text( + sub, + style = TrackProType.body.atSize(12.sp), + color = if (entry.lastLapIsBest) TrackProTheme.colors.deltaGood else TrackProTheme.colors.markingDim + ) + } + } + } + HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp, modifier = Modifier.padding(horizontal = Spacing.md)) + } +} + +/** The event's track is not on this phone: without it, the driver's laps are timed on other gates. */ +@Composable +private fun GetTrackCard(trackName: String, busy: Boolean, onGet: () -> Unit) { + Box(modifier = Modifier.padding(horizontal = Spacing.gutter, vertical = Spacing.sm)) { + Column( + modifier = Modifier + .fillMaxWidth() + .paddockCard(color = TrackProTheme.colors.deltaBad.copy(alpha = 0.10f)) + .padding(Spacing.lg) + ) { + Text(stringResource(R.string.board_get_track_title), style = TrackProType.titleMedium, color = TrackProTheme.colors.marking) + Spacer(Modifier.height(Spacing.xs)) + Text( + stringResource(R.string.board_get_track_hint, trackName), + style = TrackProType.body, + color = TrackProTheme.colors.markingDim + ) + Spacer(Modifier.height(Spacing.md)) + PrimaryButton(text = if (busy) stringResource(R.string.board_downloading) else stringResource(R.string.board_get_track, trackName), enabled = !busy, onClick = onGet, modifier = Modifier.fillMaxWidth()) + } + } +} + +private const val IDLE_REFRESH_MS = 60_000L diff --git a/app/src/main/java/com/example/trackpro/online/ui/EventsScreen.kt b/app/src/main/java/com/example/trackpro/online/ui/EventsScreen.kt new file mode 100644 index 0000000..72fa9d6 --- /dev/null +++ b/app/src/main/java/com/example/trackpro/online/ui/EventsScreen.kt @@ -0,0 +1,512 @@ +package com.example.trackpro.online.ui + +import com.example.trackpro.online.MessageText +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.automirrored.outlined.Login +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.trackpro.TrackProApp +import com.example.trackpro.components.DashTabBarHeight +import com.example.trackpro.components.EmptyState +import com.example.trackpro.components.Haptic +import com.example.trackpro.components.IconCircle +import com.example.trackpro.components.PaddockCard +import com.example.trackpro.components.PrimaryButton +import com.example.trackpro.components.ScreenScaffold +import com.example.trackpro.components.SectionTitle +import com.example.trackpro.components.ToggleChip +import com.example.trackpro.components.isScrolledUnderChrome +import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.managerClasses.utilities.toLapDeltaString +import com.example.trackpro.managerClasses.utilities.toLapTimeString +import com.example.trackpro.online.AccountState +import com.example.trackpro.online.ApiEventStatus +import com.example.trackpro.online.ApiException +import com.example.trackpro.online.EventBoardEntry +import com.example.trackpro.online.EventDetail +import com.example.trackpro.online.EventSummary +import com.example.trackpro.online.NetworkException +import com.example.trackpro.online.SyncProblem +import com.example.trackpro.online.parseInstant +import com.example.trackpro.theme.Spacing +import com.example.trackpro.theme.marking +import com.example.trackpro.theme.markingDim +import com.example.trackpro.theme.TrackProShapes +import com.example.trackpro.theme.TrackProType +import com.example.trackpro.theme.atSize +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date + +/** + * The Events tab: track days on TrackBoard. Join with the host's code, see the one that is + * live right now with your place in it, and every event you have joined or host. + * + * Hosting happens on the website; the phone is for the people driving. + */ +@Composable +fun EventsScreen(onOpenEvent: (String) -> Unit, onSignIn: () -> Unit) { + val app = LocalContext.current.applicationContext as TrackProApp + val online = app.online + val account by online.auth.state.collectAsState() + + if (account !is AccountState.SignedIn) { + ScreenScaffold(title = stringResource(R.string.tab_events)) { padding -> + EmptyState( + message = stringResource(R.string.events_signed_out), + hint = stringResource(R.string.events_signed_out_hint), + icon = Icons.AutoMirrored.Outlined.Login, + actionLabel = stringResource(R.string.common_sign_in), + onAction = onSignIn, + modifier = Modifier.padding(top = padding.calculateTopPadding()) + ) + } + return + } + + var events by remember { mutableStateOf?>(null) } + var loadError by remember { mutableStateOf(null) } + var reload by remember { mutableIntStateOf(0) } + + LaunchedEffect(reload) { + loadError = null + try { + events = online.events.refresh() + } catch (e: NetworkException) { + loadError = app.getString(R.string.events_offline) + } catch (e: ApiException) { + // A server from before events existed answers the listing with 404. + loadError = if (e.status == 404) app.getString(R.string.events_server_none) else MessageText.localize(e.message) + } + } + + val listState = rememberLazyListState() + val scrolled by listState.isScrolledUnderChrome() + val joined by online.events.joined.collectAsState() + // Offline, fall back to what the phone remembers, so a paddock with no signal still works. + val shown = events ?: joined.map { it.asSummary() } + val live = shown.firstOrNull { it.isJoined && it.status == ApiEventStatus.Live } + + ScreenScaffold(title = stringResource(R.string.tab_events), contentScrolled = scrolled) { padding -> + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(top = padding.calculateTopPadding() + Spacing.sm, bottom = DashTabBarHeight + Spacing.xxl) + ) { + if (live != null) { + item(key = "live") { LiveNowCard(live, onOpen = { onOpenEvent(live.id) }) } + } + + item(key = "join") { + SectionTitle(stringResource(R.string.events_join_title)) + JoinCard(onJoined = { id -> + reload++ + onOpenEvent(id) + }) + } + + loadError?.let { message -> + item(key = "error") { + Text( + message, + style = TrackProType.body, + color = TrackProTheme.colors.deltaBad, + modifier = Modifier.padding(horizontal = Spacing.gutter, vertical = Spacing.sm) + ) + } + } + + item(key = "mine-title") { SectionTitle(stringResource(R.string.events_yours), action = stringResource(R.string.rig_refresh), onAction = { reload++ }) } + + if (shown.isEmpty()) { + item(key = "none") { + Text( + if (events == null && loadError == null) stringResource(R.string.events_fetching) + else stringResource(R.string.events_none), + style = TrackProType.body, + color = TrackProTheme.colors.markingDim, + modifier = Modifier.padding(horizontal = Spacing.gutter) + ) + } + } + + items(shown, key = { it.id }) { event -> + EventCard(event, onClick = { onOpenEvent(event.id) }) + } + + item(key = "host-note") { + Text( + stringResource(R.string.events_host_note), + style = TrackProType.body, + color = TrackProTheme.colors.markingDim, + modifier = Modifier.padding(horizontal = Spacing.gutter, vertical = Spacing.xl) + ) + } + } + } +} + +/** + * The peak of the tab: the event running right now, with where the driver stands in it. + * Refreshed while on screen, so it moves as laps come in. + */ +@Composable +private fun LiveNowCard(event: EventSummary, onOpen: () -> Unit) { + val app = LocalContext.current.applicationContext as TrackProApp + val online = app.online + val myId = (online.auth.state.value as? AccountState.SignedIn)?.userId + var me by remember { mutableStateOf(null) } + var fieldSize by remember { mutableIntStateOf(0) } + var hasTrack by remember { mutableStateOf(true) } + + LaunchedEffect(event.id) { + hasTrack = online.events.localTrackFor(event.trackId) != null + while (true) { + runCatching { online.events.board(event.id) }.getOrNull()?.let { board -> + me = board.entries.firstOrNull { it.userId == myId } + fieldSize = board.entries.count { it.rank != null } + } + kotlinx.coroutines.delay(LIVE_REFRESH_MS) + } + } + + Column(modifier = Modifier.padding(horizontal = Spacing.gutter, vertical = Spacing.sm)) { + PaddockCard(onClick = onOpen, padding = Spacing.xl) { + Row(verticalAlignment = Alignment.CenterVertically) { + LiveDot() + Spacer(Modifier.width(Spacing.sm)) + Text(stringResource(R.string.events_live_now), style = TrackProType.label.copy(fontWeight = FontWeight.SemiBold), color = TrackProTheme.colors.deltaGood) + Spacer(Modifier.weight(1f)) + Text(timeLeft(event.endsAt), style = TrackProType.label, color = TrackProTheme.colors.markingDim) + } + Spacer(Modifier.height(Spacing.md)) + Text(event.name, style = TrackProType.titleLarge, color = TrackProTheme.colors.marking, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(event.trackName, style = TrackProType.body, color = TrackProTheme.colors.markingDim) + Spacer(Modifier.height(Spacing.lg)) + + val mine = me + if (mine?.rank != null) { + Row(verticalAlignment = Alignment.Bottom) { + // The value leads: the place is what the driver came to see. + Text( + "P${mine.rank}", + style = TrackProType.displayNumeric.atSize(48.sp), + color = if (mine.rank == 1) TrackProTheme.colors.accent else TrackProTheme.colors.marking + ) + Spacer(Modifier.width(Spacing.md)) + Column(modifier = Modifier.padding(bottom = Spacing.sm)) { + Text(stringResource(R.string.events_of_field, fieldSize), style = TrackProType.body, color = TrackProTheme.colors.markingDim) + Text( + buildString { + append(mine.bestLapMs?.toLong()?.toLapTimeString() ?: "") + mine.gapToLeaderMs?.takeIf { it > 0 }?.let { append(" · ").append(it.toLong().toLapDeltaString()) } + }, + style = TrackProType.titleMedium, + color = TrackProTheme.colors.marking + ) + } + } + } else { + Text( + if (hasTrack) stringResource(R.string.events_no_lap_yet, event.trackName) + else stringResource(R.string.events_get_track_first, event.trackName), + style = TrackProType.body, + color = TrackProTheme.colors.markingDim + ) + } + Spacer(Modifier.height(Spacing.lg)) + PrimaryButton(text = stringResource(R.string.events_open_board), onClick = onOpen, modifier = Modifier.fillMaxWidth()) + } + } +} + +/** + * Code in, event out: the code is looked up first, so the driver sees what they are joining + * and picks a run group before committing. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun JoinCard(onJoined: (String) -> Unit) { + val app = LocalContext.current.applicationContext as TrackProApp + val online = app.online + val scope = rememberCoroutineScope() + + var code by remember { mutableStateOf("") } + var found by remember { mutableStateOf(null) } + var groupId by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + fun lookUp() { + val clean = code.filter(Char::isLetterOrDigit) + if (clean.length < 4) { + error = app.getString(R.string.events_enter_code) + return + } + busy = true + error = null + scope.launch { + try { + found = online.events.lookUp(clean) + groupId = found?.event?.myGroupId + } catch (e: ApiException) { + error = if (e.status == 404) app.getString(R.string.events_no_such_code) else MessageText.localize(e.message) + } catch (e: NetworkException) { + error = app.getString(R.string.events_join_needs_connection, MessageText.localize(e.message) ?: "") + } finally { + busy = false + } + } + } + + Column(modifier = Modifier.padding(horizontal = Spacing.gutter)) { + PaddockCard(padding = Spacing.lg) { + Row(verticalAlignment = Alignment.CenterVertically) { + OutlinedTextField( + value = code, + onValueChange = { value -> + code = value.uppercase().filter { it.isLetterOrDigit() || it == ' ' || it == '-' }.take(9) + found = null + }, + label = { Text(stringResource(R.string.events_join_code), color = TrackProTheme.colors.textMuted) }, + placeholder = { Text("K7Q 2M9", color = TrackProTheme.colors.textFaint) }, + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.Characters, imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { lookUp() }), + textStyle = TrackProType.titleLarge, + modifier = Modifier.weight(1f), + colors = fieldColors() + ) + Spacer(Modifier.width(Spacing.md)) + ToggleChip(text = if (busy && found == null) "…" else stringResource(R.string.events_find), selected = false, onClick = { if (!busy) lookUp() }) + } + + error?.let { + Spacer(Modifier.height(Spacing.sm)) + Text(it, style = TrackProType.body, color = TrackProTheme.colors.deltaBad) + } + + found?.let { detail -> + val ev = detail.event + Spacer(Modifier.height(Spacing.lg)) + Row(verticalAlignment = Alignment.CenterVertically) { + IconCircle(icon = Icons.Default.Flag) + Spacer(Modifier.width(Spacing.md)) + Column(modifier = Modifier.weight(1f)) { + Text(ev.name, style = TrackProType.titleMedium, color = TrackProTheme.colors.marking) + Text( + stringResource(R.string.events_found_line, ev.trackName, window(ev.startsAt, ev.endsAt), ev.hostDisplayName), + style = TrackProType.body, + color = TrackProTheme.colors.markingDim + ) + } + } + + if (detail.groups.isNotEmpty()) { + Spacer(Modifier.height(Spacing.lg)) + Text(stringResource(R.string.events_run_group), style = TrackProType.label, color = TrackProTheme.colors.markingDim) + Spacer(Modifier.height(Spacing.sm)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + detail.groups.forEach { g -> + ToggleChip(text = g.name, selected = groupId == g.id, onClick = { groupId = if (groupId == g.id) null else g.id }) + } + } + } + + Spacer(Modifier.height(Spacing.lg)) + Text( + stringResource(R.string.events_join_shares, ev.trackName), + style = TrackProType.body, + color = TrackProTheme.colors.markingDim + ) + Spacer(Modifier.height(Spacing.lg)) + PrimaryButton( + text = when { + busy -> stringResource(R.string.events_joining) + ev.isJoined -> stringResource(R.string.events_update_group) + else -> stringResource(R.string.events_join) + }, + enabled = !busy && ev.status != ApiEventStatus.Finished, + haptic = Haptic.Confirm, + modifier = Modifier.fillMaxWidth(), + onClick = { + busy = true + error = null + scope.launch { + try { + online.events.join(code.filter(Char::isLetterOrDigit), groupId) + // The event's track: fetched now, while there is signal, so the + // session at the circuit is timed on the host's gates. + runCatching { online.events.downloadTrack(ev.trackId) } + code = "" + found = null + onJoined(ev.id) + } catch (e: ApiException) { + error = if (e.status == 409) app.getString(R.string.events_finished_short) else MessageText.localize(e.message) + } catch (e: NetworkException) { + error = app.getString(R.string.events_join_needs_connection, MessageText.localize(e.message) ?: "") + } catch (e: SyncProblem) { + error = MessageText.localize(e.message) + } finally { + busy = false + } + } + } + ) + if (ev.status == ApiEventStatus.Finished) { + Spacer(Modifier.height(Spacing.sm)) + Text(stringResource(R.string.events_finished_open), style = TrackProType.body, color = TrackProTheme.colors.markingDim) + } + } + } + } +} + +@Composable +private fun EventCard(event: EventSummary, onClick: () -> Unit) { + Box(modifier = Modifier.padding(horizontal = Spacing.gutter, vertical = Spacing.xs)) { + PaddockCard(onClick = onClick, padding = Spacing.lg) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconCircle( + icon = Icons.Default.Flag, + tint = if (event.status == ApiEventStatus.Live) TrackProTheme.colors.accent else TrackProTheme.colors.accentMuted + ) + Spacer(Modifier.width(Spacing.md)) + Column(modifier = Modifier.weight(1f)) { + Text(event.name, style = TrackProType.titleMedium, color = TrackProTheme.colors.marking, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + "${event.trackName} · ${window(event.startsAt, event.endsAt)}", + style = TrackProType.body, + color = TrackProTheme.colors.markingDim, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Spacer(Modifier.width(Spacing.sm)) + Column(horizontalAlignment = Alignment.End) { + StatusPill(event.status) + if (event.isHost) { + Spacer(Modifier.height(Spacing.xs)) + Text(stringResource(R.string.events_host), style = TrackProType.label, color = TrackProTheme.colors.markingDim) + } + } + } + } + } +} + +@Composable +internal fun StatusPill(status: ApiEventStatus) { + val (label, tone) = when (status) { + ApiEventStatus.Live -> stringResource(R.string.hud_live) to TrackProTheme.colors.deltaGood + ApiEventStatus.Upcoming -> stringResource(R.string.events_upcoming) to TrackProTheme.colors.accent + ApiEventStatus.Finished -> stringResource(R.string.events_finished) to TrackProTheme.colors.textFaint + } + Row( + modifier = Modifier + .clip(TrackProShapes.pill) + .background(tone.copy(alpha = 0.14f)) + .heightIn(min = 28.dp) + .padding(horizontal = Spacing.md, vertical = Spacing.xs), + verticalAlignment = Alignment.CenterVertically + ) { + if (status == ApiEventStatus.Live) { + LiveDot(tone) + Spacer(Modifier.width(6.dp)) + } + Text(label, style = TrackProType.label.copy(fontWeight = FontWeight.SemiBold), color = tone) + } +} + +@Composable +internal fun LiveDot(tone: Color = TrackProTheme.colors.deltaGood) { + Box(Modifier.size(8.dp).clip(CircleShape).background(tone)) +} + +/** How the phone shows a remembered event while offline: enough for the list and the live card. */ +private fun com.example.trackpro.online.JoinedEvent.asSummary(): EventSummary { + val now = System.currentTimeMillis() + val status = when { + now < startsAtMs -> ApiEventStatus.Upcoming + now > endsAtMs -> ApiEventStatus.Finished + else -> ApiEventStatus.Live + } + return EventSummary( + id = id, + name = name, + trackId = trackId, + trackName = trackName, + startsAt = java.time.Instant.ofEpochMilli(startsAtMs).toString(), + endsAt = java.time.Instant.ofEpochMilli(endsAtMs).toString(), + status = status, + isJoined = true, + myGroupId = groupId, + ) +} + +internal fun window(startsAt: String, endsAt: String): String { + val start = parseInstant(startsAt) ?: return "" + val end = parseInstant(endsAt) ?: return "" + val day = DateFormat.getDateInstance(DateFormat.MEDIUM) + val time = DateFormat.getTimeInstance(DateFormat.SHORT) + return if (day.format(Date(start)) == day.format(Date(end))) { + "${day.format(Date(start))}, ${time.format(Date(start))}–${time.format(Date(end))}" + } else { + "${day.format(Date(start))} – ${day.format(Date(end))}" + } +} + +@Composable +private fun timeLeft(endsAt: String): String { + val end = parseInstant(endsAt) ?: return "" + val minutes = ((end - System.currentTimeMillis()) / 60_000).coerceAtLeast(0) + return if (minutes >= 60) stringResource(R.string.events_left_hm, (minutes / 60).toInt(), (minutes % 60).toInt()) + else stringResource(R.string.events_left_m, minutes.toInt()) +} + +internal const val LIVE_REFRESH_MS = 5_000L diff --git a/app/src/main/java/com/example/trackpro/online/ui/LeaderboardScreen.kt b/app/src/main/java/com/example/trackpro/online/ui/LeaderboardScreen.kt index f588f87..347c396 100644 --- a/app/src/main/java/com/example/trackpro/online/ui/LeaderboardScreen.kt +++ b/app/src/main/java/com/example/trackpro/online/ui/LeaderboardScreen.kt @@ -1,5 +1,7 @@ package com.example.trackpro.online.ui +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,6 +20,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import com.example.trackpro.online.MessageText import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -76,9 +79,9 @@ fun LeaderboardScreen(trackId: Long, onBack: () -> Unit) { board = remoteId?.let { online.api.getLeaderboard(online.auth.currentAccessTokenOrNull(), it, LIMIT) } LoadState.Ready } catch (e: NetworkException) { - LoadState.Failed(e.message ?: "TrackBoard could not be reached.") + LoadState.Failed(MessageText.localize(e.message) ?: app.getString(R.string.online_unreachable)) } catch (e: ApiException) { - LoadState.Failed(e.message ?: "TrackBoard refused the request.") + LoadState.Failed(MessageText.localize(e.message) ?: app.getString(R.string.online_refused)) } } @@ -88,16 +91,16 @@ fun LeaderboardScreen(trackId: Long, onBack: () -> Unit) { val myPinned = board?.me?.takeIf { me -> entries.none { it.userId == me.userId } } ScreenScaffold( - title = "Leaderboard", + title = stringResource(R.string.leaderboard_title), subtitle = trackName, onBack = onBack ) { padding -> DataGate( state = state, items = entries, - emptyMessage = "No laps posted yet", - emptyHint = "Laps appear here once drivers who share their laps have driven this track.", - loadingLabel = "Fetching", + emptyMessage = stringResource(R.string.leaderboard_empty), + emptyHint = stringResource(R.string.leaderboard_empty_hint), + loadingLabel = stringResource(R.string.leaderboard_fetching), onRetry = { reload++ }, ) { rows -> LazyColumn( @@ -109,7 +112,7 @@ fun LeaderboardScreen(trackId: Long, onBack: () -> Unit) { if (myPinned != null) { item { Text( - "YOUR BEST", + stringResource(R.string.leaderboard_your_best), style = TrackProType.label, color = TrackProTheme.colors.textMuted, modifier = Modifier.padding(start = Spacing.lg, top = Spacing.lg, bottom = Spacing.xs) @@ -121,7 +124,7 @@ fun LeaderboardScreen(trackId: Long, onBack: () -> Unit) { Text( // Honest about provenance: these are self-reported, and the two GPS // sources differ in precision. - "Lap times are posted by drivers and not verified. Phone GPS is less precise than an ESP32 module.", + stringResource(R.string.leaderboard_disclaimer), style = TrackProType.body, color = TrackProTheme.colors.textMuted, modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.lg) @@ -165,7 +168,7 @@ private fun EntryRow(entry: LeaderboardEntry, isMe: Boolean) { ) Column(modifier = Modifier.weight(1f)) { Text( - text = if (isMe) "${entry.displayName} · YOU" else entry.displayName, + text = if (isMe) stringResource(R.string.leaderboard_you, entry.displayName) else entry.displayName, style = TrackProType.titleMedium, color = TrackProTheme.colors.textPrimary, maxLines = 1, @@ -201,9 +204,10 @@ private fun EntryRow(entry: LeaderboardEntry, isMe: Boolean) { HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) } +@Composable private fun sourceLabel(source: ApiGpsSource): String = when (source) { ApiGpsSource.Wifi, ApiGpsSource.Bluetooth -> "ESP32" - ApiGpsSource.PhoneGps -> "PHONE GPS" + ApiGpsSource.PhoneGps -> stringResource(R.string.rig_phone_gps) } private const val LIMIT = 50 diff --git a/app/src/main/java/com/example/trackpro/online/ui/OnlineSettingsGroup.kt b/app/src/main/java/com/example/trackpro/online/ui/OnlineSettingsGroup.kt index a1e8941..02bf3c4 100644 --- a/app/src/main/java/com/example/trackpro/online/ui/OnlineSettingsGroup.kt +++ b/app/src/main/java/com/example/trackpro/online/ui/OnlineSettingsGroup.kt @@ -1,5 +1,8 @@ package com.example.trackpro.online.ui +import com.example.trackpro.online.MessageText +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -55,13 +58,13 @@ fun OnlineSettingsGroup() { val account by online.auth.state.collectAsState() val serverUrl by online.settings.serverUrl.collectAsState() - DashGroup("Online · TrackBoard") { + DashGroup(stringResource(R.string.online_group)) { Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { ServerRow(url = serverUrl, onChange = online.settings::setServerUrl) Text( when (val state = account) { - is AccountState.SignedIn -> "Signed in as ${state.displayName}. Your account, lap sharing and sync are on the Profile tab." - is AccountState.SignedOut -> "Optional. Sign in from the Profile tab to back up your garage and post laps to leaderboards. Recording never needs an account or a network." + is AccountState.SignedIn -> stringResource(R.string.online_signed_in_as, state.displayName) + is AccountState.SignedOut -> stringResource(R.string.online_signed_out) }, style = TrackProType.body, color = TrackProTheme.colors.textMuted @@ -73,12 +76,12 @@ fun OnlineSettingsGroup() { @Composable internal fun ServerRow(url: String, onChange: (String) -> Unit) { Column { - Placard("Server") + Placard(stringResource(R.string.online_server)) Spacer(Modifier.height(6.dp)) OutlinedTextField( value = url, onValueChange = onChange, - placeholder = { Text("e.g. http://10.0.2.2:5000", color = TrackProTheme.colors.textMuted) }, + placeholder = { Text(stringResource(R.string.online_server_hint), color = TrackProTheme.colors.textMuted) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), modifier = Modifier.fillMaxWidth(), @@ -103,20 +106,20 @@ internal fun AccountSignInForm(notice: String?, serverSet: Boolean, onSignedIn: var error by remember { mutableStateOf(null) } Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { - Placard("Account") + Placard(stringResource(R.string.profile_account)) // An involuntary sign-out (expired session) is a fault the driver needs to see. - notice?.let { Text(it, style = TrackProType.body, color = TrackProTheme.colors.deltaBad) } + notice?.let { Text(MessageText.localize(it) ?: it, style = TrackProType.body, color = TrackProTheme.colors.deltaBad) } Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { ToggleChip( - text = "Sign in", + text = stringResource(R.string.common_sign_in), selected = !creating, onClick = { creating = false; error = null }, modifier = Modifier.weight(1f) ) ToggleChip( - text = "Create account", + text = stringResource(R.string.online_create_account), selected = creating, onClick = { creating = true; error = null }, modifier = Modifier.weight(1f) @@ -126,7 +129,7 @@ internal fun AccountSignInForm(notice: String?, serverSet: Boolean, onSignedIn: OutlinedTextField( value = email, onValueChange = { email = it }, - label = { Text("Email", color = TrackProTheme.colors.textMuted) }, + label = { Text(stringResource(R.string.online_email), color = TrackProTheme.colors.textMuted) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), modifier = Modifier.fillMaxWidth(), @@ -137,7 +140,7 @@ internal fun AccountSignInForm(notice: String?, serverSet: Boolean, onSignedIn: OutlinedTextField( value = displayName, onValueChange = { displayName = it }, - label = { Text("Name on leaderboards", color = TrackProTheme.colors.textMuted) }, + label = { Text(stringResource(R.string.account_name_on_boards), color = TrackProTheme.colors.textMuted) }, singleLine = true, modifier = Modifier.fillMaxWidth(), colors = fieldColors() @@ -147,7 +150,7 @@ internal fun AccountSignInForm(notice: String?, serverSet: Boolean, onSignedIn: OutlinedTextField( value = password, onValueChange = { password = it }, - label = { Text(if (creating) "Password (12+ characters)" else "Password", color = TrackProTheme.colors.textMuted) }, + label = { Text(if (creating) stringResource(R.string.online_password_new) else stringResource(R.string.online_password), color = TrackProTheme.colors.textMuted) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), @@ -158,16 +161,16 @@ internal fun AccountSignInForm(notice: String?, serverSet: Boolean, onSignedIn: error?.let { Text(it, style = TrackProType.body, color = TrackProTheme.colors.deltaBad) } if (!serverSet) { - Text("Set a server first.", style = TrackProType.body, color = TrackProTheme.colors.textMuted) + Text(stringResource(R.string.online_set_server_first), style = TrackProType.body, color = TrackProTheme.colors.textMuted) } val formComplete = email.isNotBlank() && password.isNotEmpty() && (!creating || displayName.isNotBlank()) PrimaryButton( text = when { - busy -> "Working…" - creating -> "Create account" - else -> "Sign in" + busy -> stringResource(R.string.online_working) + creating -> stringResource(R.string.online_create_account) + else -> stringResource(R.string.common_sign_in) }, enabled = serverSet && formComplete && !busy, onClick = { @@ -182,9 +185,9 @@ internal fun AccountSignInForm(notice: String?, serverSet: Boolean, onSignedIn: SyncScheduler.syncSoon(context) onSignedIn() } catch (e: ApiException) { - error = e.message + error = MessageText.localize(e.message) } catch (e: NetworkException) { - error = e.message + error = MessageText.localize(e.message) } finally { busy = false } @@ -197,7 +200,7 @@ internal fun AccountSignInForm(notice: String?, serverSet: Boolean, onSignedIn: @Composable private fun Placard(text: String) { - Text(text.uppercase(), style = TrackProType.label, color = TrackProTheme.colors.textPrimary) + Text(text, style = TrackProType.label, color = TrackProTheme.colors.textPrimary) } @Composable diff --git a/app/src/main/java/com/example/trackpro/online/ui/TrackOnlineStrip.kt b/app/src/main/java/com/example/trackpro/online/ui/TrackOnlineStrip.kt index 78d0542..8445f01 100644 --- a/app/src/main/java/com/example/trackpro/online/ui/TrackOnlineStrip.kt +++ b/app/src/main/java/com/example/trackpro/online/ui/TrackOnlineStrip.kt @@ -1,5 +1,7 @@ package com.example.trackpro.online.ui +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -37,7 +39,7 @@ import kotlinx.coroutines.withContext * The track screen's line into TrackBoard: whether this track has a leaderboard, a way to * publish one the driver built, and the way in. * - * Premade tracks are shared by every install automatically, so they only ever offer "Open". + * Premade tracks are shared by every install automatically, so they only ever offer stringResource(R.string.strip_open). */ @Composable fun TrackOnlineStrip(trackId: Long, onOpenLeaderboard: () -> Unit) { @@ -58,10 +60,10 @@ fun TrackOnlineStrip(trackId: Long, onOpenLeaderboard: () -> Unit) { val signedIn = account is AccountState.SignedIn val status = when { premade == null -> "" - premade == true -> "Shared track · every TrackPro driver" - published -> "Published by you" - signedIn -> "Private · publish it to open a leaderboard" - else -> "Private · sign in under Settings to publish" + premade == true -> stringResource(R.string.strip_shared) + published -> stringResource(R.string.strip_published) + signedIn -> stringResource(R.string.strip_private_publish) + else -> stringResource(R.string.strip_private_sign_in) } Row( @@ -73,14 +75,14 @@ fun TrackOnlineStrip(trackId: Long, onOpenLeaderboard: () -> Unit) { horizontalArrangement = Arrangement.spacedBy(Spacing.sm) ) { Column(modifier = Modifier.weight(1f)) { - Text("LEADERBOARD", style = TrackProType.label, color = TrackProTheme.colors.textPrimary) + Text(stringResource(R.string.leaderboard_title), style = TrackProType.label, color = TrackProTheme.colors.textPrimary) Spacer(Modifier.height(2.dp)) Text(status, style = TrackProType.body, color = TrackProTheme.colors.textMuted) } if (premade == false && signedIn) { ToggleChip( - text = if (published) "Unpublish" else "Publish", + text = if (published) stringResource(R.string.strip_unpublish) else stringResource(R.string.strip_publish), selected = published, onClick = { scope.launch(Dispatchers.IO) { @@ -96,7 +98,7 @@ fun TrackOnlineStrip(trackId: Long, onOpenLeaderboard: () -> Unit) { // Drawn disabled rather than hidden, so the control's place is learned before it works. val canOpen = premade == true || published ToggleChip( - text = "Open", + text = stringResource(R.string.strip_open), selected = canOpen, onClick = { if (canOpen) onOpenLeaderboard() } ) diff --git a/app/src/main/java/com/example/trackpro/screens/ESPConnectionTestScreen.kt b/app/src/main/java/com/example/trackpro/screens/ESPConnectionTestScreen.kt index e269534..bab92e3 100644 --- a/app/src/main/java/com/example/trackpro/screens/ESPConnectionTestScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/ESPConnectionTestScreen.kt @@ -1,5 +1,7 @@ package com.example.trackpro.screens +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import android.annotation.SuppressLint import android.graphics.Typeface import androidx.compose.animation.core.animateFloatAsState @@ -20,6 +22,8 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import com.example.trackpro.online.MessageText +import com.example.trackpro.components.paddockCard import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -92,11 +96,12 @@ fun ESPConnectionTestScreen( val config = remember { JsonReader.loadConfig(context) } val ip = if (useTestServer && testServerAddress.isNotBlank()) testServerAddress else config.first val port = config.second - val pairedDeviceLabel = remember(selectedBtDeviceMac) { + val noneSelected = stringResource(R.string.rig_none_selected) + val pairedDeviceLabel = remember(selectedBtDeviceMac, noneSelected) { app.bluetoothClassicClient.getBondedDevices() .find { it.address == selectedBtDeviceMac } ?.let { it.name ?: it.address } - ?: "None selected" + ?: noneSelected } // 3. Derived UI values @@ -116,9 +121,9 @@ fun ESPConnectionTestScreen( AppTopBar( title = when (gpsSource) { - GpsProviderType.WIFI -> "ESP32 (WiFi) Mode" - GpsProviderType.BLUETOOTH -> "ESP32 (Bluetooth) Mode" - GpsProviderType.PHONE_GPS -> "Phone GPS Mode" + GpsProviderType.WIFI -> stringResource(R.string.rig_mode_wifi) + GpsProviderType.BLUETOOTH -> stringResource(R.string.rig_mode_bt) + GpsProviderType.PHONE_GPS -> stringResource(R.string.rig_mode_phone) }, // The bar's dot is the first thing read on this screen, and when the rig // is down it is reporting a fault - so it takes danger, not the dimmest @@ -128,7 +133,7 @@ fun ESPConnectionTestScreen( onBack = onBack, trailing = { Text( - text = "Change", + text = stringResource(R.string.rig_change), style = TrackProType.label, color = TrackProTheme.colors.accent, // Bare text was the smallest tap target on the screen; give it @@ -157,8 +162,9 @@ fun ESPConnectionTestScreen( Column( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.field) - .padding(horizontal = 14.dp, vertical = 14.dp) + .padding(horizontal = Spacing.gutter) + .paddockCard() + .padding(Spacing.lg) ) { Readout( // 2 km/h of hysteresis: enough that a stationary module reading @@ -168,7 +174,7 @@ fun ESPConnectionTestScreen( threshold = 2f ), value = UnitFormatter.formatSpeed(speed, useMetric), - caption = "Ground speed \u00b7 ${UnitFormatter.speedUnitLabel(useMetric)}", + caption = stringResource(R.string.rig_ground_speed, UnitFormatter.speedUnitLabel(useMetric)), valueColor = if (isConnected) TrackProTheme.colors.marking else TrackProTheme.colors.markingDim, valueSize = 64.sp @@ -185,33 +191,33 @@ fun ESPConnectionTestScreen( ) } - Bezel() + Spacer(Modifier.height(Spacing.md)) // -- Link state ---------------------------- - Row(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.gutter).paddockCard()) { Instrument( - label = "Source", + label = stringResource(R.string.rig_source), value = when (gpsSource) { - GpsProviderType.WIFI -> "ESP32 WIFI" + GpsProviderType.WIFI -> "ESP32 Wi-Fi" GpsProviderType.BLUETOOTH -> "ESP32 BT" - GpsProviderType.PHONE_GPS -> "INTERNAL" + GpsProviderType.PHONE_GPS -> stringResource(R.string.rig_phone_gps) }, valueSize = 15.sp, modifier = Modifier.weight(1f) ) Bezel(vertical = true, modifier = Modifier.height(58.dp)) Instrument( - label = "Link", - value = if (isConnected) "LIVE" else "OFFLINE", + label = stringResource(R.string.rig_link), + value = if (isConnected) stringResource(R.string.hud_live) else stringResource(R.string.rig_offline), valueColor = if (isConnected) TrackProTheme.colors.deltaGood - else TrackProTheme.colors.deltaBad, + else TrackProTheme.colors.danger, valueSize = 15.sp, modifier = Modifier.weight(1f) ) Bezel(vertical = true, modifier = Modifier.height(58.dp)) Instrument( - label = "Fix", - value = if (fix) "LOCKED" else "SEARCHING", + label = stringResource(R.string.rig_fix), + value = if (fix) stringResource(R.string.rig_locked) else stringResource(R.string.rig_searching), valueColor = if (fix) TrackProTheme.colors.deltaGood else TrackProTheme.colors.deltaBad, valueSize = 15.sp, @@ -219,7 +225,7 @@ fun ESPConnectionTestScreen( ) } - Bezel() + Spacer(Modifier.height(Spacing.md)) // -- Diagnosis ----------------------------- // PRODUCT.md principle 2: a stranger has to succeed alone. This screen used @@ -230,31 +236,33 @@ fun ESPConnectionTestScreen( Column( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.field) - .padding(horizontal = 14.dp, vertical = 14.dp) + .padding(horizontal = Spacing.gutter) + .paddockCard() + .padding(Spacing.lg) ) { Text( - text = cause.uppercase(), - style = TrackProType.label, - color = if (!isConnected) TrackProTheme.colors.deltaBad - else TrackProTheme.colors.markingDim + text = cause, + style = TrackProType.titleMedium, + color = if (!isConnected) TrackProTheme.colors.danger + else TrackProTheme.colors.deltaBad ) Spacer(Modifier.height(6.dp)) Text( text = recovery, - style = TrackProType.body.atSize(13.sp), + style = TrackProType.body, color = TrackProTheme.colors.marking ) Spacer(Modifier.height(14.dp)) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { DashAction( - label = "Retry link", + label = stringResource(R.string.rig_retry_link), onClick = { app.gpsManager.retryActiveProvider() }, compact = true, + primary = true, modifier = Modifier.weight(1f) ) DashAction( - label = "Change source", + label = stringResource(R.string.rig_change_source), onClick = onNavigateToSettings, compact = true, accent = TrackProTheme.colors.markingDim, @@ -264,51 +272,50 @@ fun ESPConnectionTestScreen( if (lastFailure != null) { Spacer(Modifier.height(10.dp)) Text( - text = "Reported: $lastFailure", + text = stringResource(R.string.rig_reported, MessageText.localize(lastFailure) ?: ""), style = TrackProType.label, color = TrackProTheme.colors.markingDim ) } } - Bezel() } - DashGroup("Data stream") { + DashGroup(stringResource(R.string.rig_data_stream)) { when (gpsSource) { GpsProviderType.WIFI -> TelemetryRow( - if (useTestServer) "Remote IP (test)" else "Remote IP", + if (useTestServer) stringResource(R.string.rig_remote_ip_test) else stringResource(R.string.rig_remote_ip), "$ip:$port", if (useTestServer) TrackProTheme.colors.accent else TrackProTheme.colors.marking, TrackProTheme.colors.markingDim ) GpsProviderType.BLUETOOTH -> TelemetryRow( - "Paired device", pairedDeviceLabel, + stringResource(R.string.rig_paired_device), pairedDeviceLabel, TrackProTheme.colors.marking, TrackProTheme.colors.markingDim ) GpsProviderType.PHONE_GPS -> {} } - TelemetryRow("Latitude", gpsData?.latitude?.let { String.format(Locale.US, "%.6f\u00b0", it) } ?: "\u2014", TrackProTheme.colors.marking, TrackProTheme.colors.markingDim) - TelemetryRow("Longitude", gpsData?.longitude?.let { String.format(Locale.US, "%.6f\u00b0", it) } ?: "\u2014", TrackProTheme.colors.marking, TrackProTheme.colors.markingDim) - TelemetryRow("Altitude", gpsData?.altitude?.let { String.format(Locale.US, "%.1f m", it) } ?: "\u2014", TrackProTheme.colors.marking, TrackProTheme.colors.markingDim) + TelemetryRow(stringResource(R.string.rig_latitude), gpsData?.latitude?.let { String.format(Locale.US, "%.6f\u00b0", it) } ?: "\u2014", TrackProTheme.colors.marking, TrackProTheme.colors.markingDim) + TelemetryRow(stringResource(R.string.rig_longitude), gpsData?.longitude?.let { String.format(Locale.US, "%.6f\u00b0", it) } ?: "\u2014", TrackProTheme.colors.marking, TrackProTheme.colors.markingDim) + TelemetryRow(stringResource(R.string.rig_altitude), gpsData?.altitude?.let { String.format(Locale.US, "%.1f m", it) } ?: "\u2014", TrackProTheme.colors.marking, TrackProTheme.colors.markingDim) TelemetryRow( - "Refresh", + stringResource(R.string.rig_refresh), when { gpsSource == GpsProviderType.PHONE_GPS -> "1-5 Hz" confirmedRateHz != null -> "$confirmedRateHz Hz" - else -> "$selectedRateHz Hz (pending)" + else -> stringResource(R.string.rig_rate_pending, selectedRateHz) }, TrackProTheme.colors.marking, TrackProTheme.colors.markingDim ) } - DashGroup("Raw packet") { + DashGroup(stringResource(R.string.rig_raw_packet)) { Text( - text = gpsData?.toString() ?: "Awaiting data stream\u2026", + text = gpsData?.toString() ?: stringResource(R.string.rig_awaiting), color = if (gpsData != null) TrackProTheme.colors.deltaGood else TrackProTheme.colors.markingDim, - style = TrackProType.body.atSize(10.sp), - lineHeight = 15.sp + style = TrackProType.label.copy(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace), + lineHeight = 16.sp ) } @@ -328,10 +335,10 @@ private fun TelemetryRow(label: String, value: String, textPrimary: Color, textM horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text(label.uppercase(), style = TrackProType.label, color = textMuted) + Text(label, style = TrackProType.body, color = textMuted) Text( value, - style = TrackProType.body.atSize(13.sp).copy(fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace), + style = TrackProType.titleMedium, color = textPrimary ) } @@ -344,6 +351,7 @@ private fun TelemetryRow(label: String, value: String, textPrimary: Color, textM * opened, whether the provider reported a failure and what it said, and whether a fix has * been acquired. It does not guess at causes it cannot see. */ +@Composable private fun rigDiagnosis( source: GpsProviderType, isConnected: Boolean, @@ -353,9 +361,7 @@ private fun rigDiagnosis( port: Int ): Pair { if (isConnected && !fix) { - return "Linked, no satellite fix" to - "The module is connected and talking. It has not locked onto satellites yet - " + - "that needs an open view of the sky and can take a minute from cold." + return stringResource(R.string.rig_no_fix) to stringResource(R.string.rig_no_fix_hint) } return when (source) { GpsProviderType.WIFI -> { @@ -364,23 +370,12 @@ private fun rigDiagnosis( val timedOut = lastFailure?.contains("timeout", true) == true || lastFailure?.contains("timed out", true) == true when { - refused -> "Nothing listening at $ip:$port" to - "The phone reached that address but nothing answered on port $port. The " + - "ESP32 is powered but its firmware may not be running - reflash from the " + - "TrackPro_ESP repo." - timedOut -> "No route to $ip" to - "The phone could not reach the module at all. Join the ESP32's Wi-Fi " + - "network in Android settings - it does not route over mobile data." - else -> "Not connected to $ip:$port" to - "Check the ESP32 is powered, then that this phone is joined to its Wi-Fi " + - "network rather than your home or phone network." + refused -> stringResource(R.string.rig_refused, ip, port) to stringResource(R.string.rig_refused_hint, port) + timedOut -> stringResource(R.string.rig_no_route, ip) to stringResource(R.string.rig_no_route_hint) + else -> stringResource(R.string.rig_not_connected, ip, port) to stringResource(R.string.rig_not_connected_hint) } } - GpsProviderType.BLUETOOTH -> "Serial link not open" to - "Check the ESP32 is powered and still paired in Android Bluetooth settings. A " + - "module that was unpaired or reset has to be paired again before it appears here." - GpsProviderType.PHONE_GPS -> "Phone GPS not reporting" to - "Allow location access for TrackPro and go outdoors with a clear view of the sky. " + - "Phone GPS also samples far slower than the ESP rig, so times will be coarser." + GpsProviderType.BLUETOOTH -> stringResource(R.string.rig_serial_closed) to stringResource(R.string.rig_serial_closed_hint) + GpsProviderType.PHONE_GPS -> stringResource(R.string.rig_phone_silent) to stringResource(R.string.rig_phone_silent_hint) } } diff --git a/app/src/main/java/com/example/trackpro/screens/SettingsScreen.kt b/app/src/main/java/com/example/trackpro/screens/SettingsScreen.kt index a03bc05..a0a83ff 100644 --- a/app/src/main/java/com/example/trackpro/screens/SettingsScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/SettingsScreen.kt @@ -1,5 +1,7 @@ package com.example.trackpro.screens +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import com.example.trackpro.managerClasses.timeAttackManagers.DeltaReference import android.annotation.SuppressLint import android.bluetooth.BluetoothDevice @@ -16,6 +18,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -25,6 +28,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.trackpro.TrackProApp +import com.example.trackpro.managerClasses.utilities.AppLanguage +import com.example.trackpro.managerClasses.utilities.findActivity import com.example.trackpro.components.DashGroup import com.example.trackpro.theme.markingDim import com.example.trackpro.components.ScreenScaffold @@ -48,15 +53,19 @@ fun SettingsScreen(onBack: () -> Unit, onRequestBluetoothPermission: () -> Unit) val selectedBtDeviceMac by app.selectedBtDeviceMac.collectAsState() val useTestServer by app.useTestServer.collectAsState() val testServerAddress by app.testServerAddress.collectAsState() - val useDarkTheme by app.useDarkTheme.collectAsState() + val sunlight by app.useSunlightContrast.collectAsState() + val appVersion = remember { + runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }.getOrNull() ?: "—" + } val useMetric by app.useMetricUnits.collectAsState() val deltaReference by app.deltaReference.collectAsState() + val language by app.appLanguage.collectAsState() val scrollState = rememberScrollState() val scrolled by scrollState.isScrolledUnderChrome() ScreenScaffold( - title = "Settings", + title = stringResource(R.string.settings_title), accent = TrackProTheme.colors.textMuted, onBack = onBack, contentScrolled = scrolled @@ -66,8 +75,23 @@ fun SettingsScreen(onBack: () -> Unit, onRequestBluetoothPermission: () -> Unit) .verticalScroll(scrollState) .padding(top = contentPadding.calculateTopPadding()), ) { + // --- Section: Language --- + DashGroup(stringResource(R.string.settings_language)) { + LanguageRow( + selected = language, + onSelect = { tag -> + if (tag != language) { + app.setAppLanguage(tag) + // Every string on screen was read at composition; recreating is + // what re-reads them. The back stack is restored on the way. + context.findActivity()?.recreate() + } + } + ) + } + // --- Section: Hardware & GPS --- - DashGroup("Hardware & Sensors") { + DashGroup(stringResource(R.string.settings_hardware)) { Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { GpsSourceRow( selected = gpsSource, @@ -106,43 +130,43 @@ fun SettingsScreen(onBack: () -> Unit, onRequestBluetoothPermission: () -> Unit) } // --- Section: Appearance --- - DashGroup("Appearance") { + DashGroup(stringResource(R.string.settings_appearance)) { SettingsToggleRow( - label = "Theme", - valueText = if (useDarkTheme) "Dark" else "Light", + label = stringResource(R.string.settings_sunlight), + valueText = if (sunlight) stringResource(R.string.settings_sunlight_on) else stringResource(R.string.settings_sunlight_off), valueColor = TrackProTheme.colors.textMuted, - buttonText = if (useDarkTheme) "Use Light" else "Use Dark", - isActive = true, - onClick = { app.setDarkTheme(!useDarkTheme) } + buttonText = if (sunlight) stringResource(R.string.common_on) else stringResource(R.string.common_off), + isActive = sunlight, + onClick = { app.setSunlightContrast(!sunlight) } ) } // --- Section: Units --- - DashGroup("Units") { + DashGroup(stringResource(R.string.settings_units)) { SettingsToggleRow( - label = "Speed & Distance", - valueText = if (useMetric) "Metric (km/h, km)" else "Imperial (mph, mi)", + label = stringResource(R.string.settings_speed_distance), + valueText = if (useMetric) stringResource(R.string.settings_metric) else stringResource(R.string.settings_imperial), valueColor = TrackProTheme.colors.textMuted, - buttonText = if (useMetric) "Use mph" else "Use km/h", + buttonText = if (useMetric) stringResource(R.string.settings_use_mph) else stringResource(R.string.settings_use_kmh), isActive = true, onClick = { app.setMetricUnits(!useMetric) } ) } // --- Section: Timing --- - DashGroup("Timing") { + DashGroup(stringResource(R.string.settings_timing)) { SettingsToggleRow( - label = "Live Delta", + label = stringResource(R.string.settings_live_delta), valueText = if (deltaReference == DeltaReference.TRACK_BEST) { - "Against your best lap on the track" + stringResource(R.string.settings_delta_track) } else { - "Against this session's best lap" + stringResource(R.string.settings_delta_session) }, valueColor = TrackProTheme.colors.textMuted, buttonText = if (deltaReference == DeltaReference.TRACK_BEST) { - "Use session best" + stringResource(R.string.settings_use_session_best) } else { - "Use track best" + stringResource(R.string.settings_use_track_best) }, isActive = true, onClick = { @@ -157,11 +181,10 @@ fun SettingsScreen(onBack: () -> Unit, onRequestBluetoothPermission: () -> Unit) OnlineSettingsGroup() // --- Section: System --- - DashGroup("Application") { + DashGroup(stringResource(R.string.settings_application)) { Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { - SettingsInfoRow(label = "App Version", value = "1.0.4-PRO") - SettingsInfoRow(label = "Database Status", value = "Connected") - SettingsInfoRow(label = "Map & Track Data", value = "© OpenStreetMap contributors") + SettingsInfoRow(label = stringResource(R.string.settings_version), value = appVersion) + SettingsInfoRow(label = stringResource(R.string.settings_map_data), value = stringResource(R.string.settings_osm)) } } @@ -170,10 +193,42 @@ fun SettingsScreen(onBack: () -> Unit, onRequestBluetoothPermission: () -> Unit) } } +@Composable +private fun LanguageRow(selected: String, onSelect: (String) -> Unit) { + Column { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + ) { + // Each language names itself, so it can be found from either. + listOf( + AppLanguage.SYSTEM to stringResource(R.string.settings_language_system), + AppLanguage.ENGLISH to "English", + AppLanguage.HUNGARIAN to "Magyar", + ).forEach { (tag, label) -> + ToggleChip( + text = label, + selected = selected == tag, + onClick = { onSelect(tag) }, + modifier = Modifier.weight(1f) + ) + } + } + if (selected == AppLanguage.SYSTEM) { + Spacer(Modifier.height(6.dp)) + Text( + stringResource(R.string.settings_language_system_hint), + style = TrackProType.label, + color = TrackProTheme.colors.textMuted + ) + } + } +} + @Composable private fun GpsSourceRow(selected: GpsProviderType, onSelect: (GpsProviderType) -> Unit) { Column { - Text("GPS SOURCE", style = TrackProType.label, color = TrackProTheme.colors.textPrimary) + Text(stringResource(R.string.settings_gps_source), style = TrackProType.label, color = TrackProTheme.colors.textPrimary) Spacer(Modifier.height(6.dp)) Row( modifier = Modifier.fillMaxWidth(), @@ -191,10 +246,11 @@ private fun GpsSourceRow(selected: GpsProviderType, onSelect: (GpsProviderType) } } +@Composable private fun gpsSourceLabel(source: GpsProviderType): String = when (source) { - GpsProviderType.WIFI -> "WiFi" - GpsProviderType.BLUETOOTH -> "Bluetooth" - GpsProviderType.PHONE_GPS -> "Phone" + GpsProviderType.WIFI -> stringResource(R.string.gps_source_wifi) + GpsProviderType.BLUETOOTH -> stringResource(R.string.gps_source_bluetooth) + GpsProviderType.PHONE_GPS -> stringResource(R.string.gps_source_phone) } @SuppressLint("MissingPermission") @@ -206,17 +262,17 @@ private fun BluetoothDeviceRow( onSelect: (BluetoothDevice) -> Unit ) { val selectedLabel = devices.find { it.address == selectedMac }?.let { it.name ?: it.address } - ?: "Select a paired device" + ?: stringResource(R.string.settings_select_paired) AppDropdownField( - label = "Bluetooth Device", + label = stringResource(R.string.settings_bt_device), items = devices, selectedLabel = selectedLabel, itemLabel = { it.name ?: it.address }, onSelect = onSelect, emptyMessage = if (hasPermission) { - "No paired devices — pair the ESP32 in Android Bluetooth settings first" + stringResource(R.string.settings_no_paired) } else { - "Bluetooth permission needed — tap WiFi then Bluetooth again to re-prompt" + stringResource(R.string.settings_bt_permission) } ) } @@ -235,16 +291,16 @@ private fun EspTargetRow( verticalAlignment = Alignment.CenterVertically ) { Column { - Text("ESP TARGET", style = TrackProType.label, color = TrackProTheme.colors.textPrimary) + Text(stringResource(R.string.settings_esp_target), style = TrackProType.label, color = TrackProTheme.colors.textPrimary) Spacer(Modifier.height(2.dp)) Text( - text = if (useTestServer) "Test Simulator" else "Real Device (192.168.4.1)", + text = if (useTestServer) stringResource(R.string.settings_test_sim) else stringResource(R.string.settings_real_device), style = TrackProType.body, color = TrackProTheme.colors.textMuted ) } ToggleChip( - text = if (useTestServer) "Use Real Device" else "Use Test Simulator", + text = if (useTestServer) stringResource(R.string.settings_use_real) else stringResource(R.string.settings_use_sim), selected = useTestServer, onClick = { onToggle(!useTestServer) }, accent = TrackProTheme.colors.accent @@ -256,8 +312,8 @@ private fun EspTargetRow( OutlinedTextField( value = testServerAddress, onValueChange = onAddressChange, - label = { Text("Simulator IP Address", color = TrackProTheme.colors.textMuted) }, - placeholder = { Text("e.g. 192.168.1.50", color = TrackProTheme.colors.markingDim) }, + label = { Text(stringResource(R.string.settings_sim_ip), color = TrackProTheme.colors.textMuted) }, + placeholder = { Text(stringResource(R.string.settings_sim_ip_hint), color = TrackProTheme.colors.markingDim) }, singleLine = true, modifier = Modifier.fillMaxWidth(), colors = OutlinedTextFieldDefaults.colors( @@ -275,10 +331,10 @@ private fun EspTargetRow( private fun GpsRateRow(selectedHz: Int, confirmedHz: Int?, onSelect: (Int) -> Unit) { Column { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { - Text("GPS RATE", style = TrackProType.label, color = TrackProTheme.colors.textPrimary) + Text(stringResource(R.string.settings_gps_rate), style = TrackProType.label, color = TrackProTheme.colors.textPrimary) if (confirmedHz != null) { Text( - text = if (confirmedHz == selectedHz) "· confirmed" else "· device at ${confirmedHz}Hz", + text = if (confirmedHz == selectedHz) stringResource(R.string.settings_rate_confirmed) else stringResource(R.string.settings_rate_device, confirmedHz), style = TrackProType.body.atSize(10.sp), // A mismatch between requested and confirmed rate is a real problem // worth flagging, so this is one of the few places color is earned. @@ -324,7 +380,7 @@ private fun SettingsToggleRow( verticalAlignment = Alignment.CenterVertically ) { Column { - Text(label.uppercase(), style = TrackProType.label, color = TrackProTheme.colors.textPrimary) + Text(label, style = TrackProType.label, color = TrackProTheme.colors.textPrimary) Spacer(Modifier.height(2.dp)) Text(valueText, style = TrackProType.body, color = valueColor) } diff --git a/app/src/main/java/com/example/trackpro/screens/TrackBuilderScreen.kt b/app/src/main/java/com/example/trackpro/screens/TrackBuilderScreen.kt index 9f8ab19..e49f524 100644 --- a/app/src/main/java/com/example/trackpro/screens/TrackBuilderScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/TrackBuilderScreen.kt @@ -1,4 +1,6 @@ package com.example.trackpro.screens +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import com.example.trackpro.components.KeepScreenOn import android.content.Context import android.util.Log @@ -184,7 +186,7 @@ fun TrackBuilderScreen( Box(modifier = Modifier.fillMaxSize().background(TrackProTheme.colors.bgDeep)) { Column(modifier = Modifier.fillMaxSize()) { - AppTopBar(title = "Track Builder", accent = TrackProTheme.colors.accent, onBack = onBack) + AppTopBar(title = stringResource(R.string.builder_title), accent = TrackProTheme.colors.accent, onBack = onBack) Column(modifier = Modifier.padding(Spacing.md)) { TrackInfoCard(trackName, countryName, trackMode) { showInfoDialog = true } @@ -345,6 +347,9 @@ fun MapLibreBuilderView( onMapTap: (LatLng) -> Unit ) { var mapLibreMap by remember { mutableStateOf(null) } + val startTitle = stringResource(R.string.builder_marker_start) + val finishTitle = stringResource(if (trackMode == "Circuit") R.string.builder_marker_start_finish else R.string.builder_marker_finish) + val waypointTitle = stringResource(R.string.builder_marker_waypoint) val sectorMarkCount = points.count { it.isSectorPoint } LaunchedEffect(points.size, sectorMarkCount, waypoints.size) { @@ -368,7 +373,7 @@ fun MapLibreBuilderView( // Start Marker is always there map.addMarker(MarkerOptions() .position(LatLng(points.first().latitude, points.first().longitude)) - .title("START") + .title(startTitle) ) if (points.size > 1) { @@ -376,7 +381,7 @@ fun MapLibreBuilderView( map.addMarker(MarkerOptions() .position(LatLng(lastPoint.latitude, lastPoint.longitude)) // Change title based on intent - .title(if (trackMode == "Circuit") "LAP COMPLETE" else "FINISH LINE") + .title(finishTitle) ) } @@ -397,7 +402,7 @@ fun MapLibreBuilderView( map.addMarker( MarkerOptions() .position(LatLng(wp.lat, wp.lon)) - .title("WAYPOINT ${index + 1}") + .title(String.format(waypointTitle, index + 1)) ) } @@ -434,24 +439,24 @@ fun MapLibreBuilderView( @Composable private fun TrackInfoCard(name: String, country: String, mode: String, onClick: () -> Unit) { - DashGroup("Track configuration") { + DashGroup(stringResource(R.string.builder_config)) { Row(verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text( - if (name.isEmpty()) "Unnamed track" else name, + if (name.isEmpty()) stringResource(R.string.builder_unnamed) else name, style = TrackProType.titleLarge.atSize(18.sp), color = TrackProTheme.colors.marking ) Spacer(Modifier.height(2.dp)) Text( - listOf(country, mode.uppercase()).filter { it.isNotBlank() }.joinToString(" \u00b7 "), + listOf(country, mode).filter { it.isNotBlank() }.joinToString(" \u00b7 "), style = TrackProType.label, color = TrackProTheme.colors.markingDim ) } Spacer(Modifier.width(12.dp)) DashAction( - label = "Edit", + label = stringResource(R.string.builder_edit), onClick = onClick, compact = true, modifier = Modifier.width(96.dp) @@ -464,7 +469,7 @@ private fun TrackInfoCard(name: String, country: String, mode: String, onClick: @Composable private fun MarkSectorButton(count: Int, enabled: Boolean, onClick: () -> Unit) { DashAction( - label = "Mark sector ${count + 1}", + label = stringResource(R.string.builder_mark_sector, count + 1), onClick = onClick, enabled = enabled, compact = true, @@ -492,7 +497,7 @@ private fun RoutingStatusRow(isRouting: Boolean) { color = TrackProTheme.colors.accent ) Text( - text = "Snapping to roads...", + text = stringResource(R.string.builder_snapping), color = TrackProTheme.colors.textMuted, fontSize = 11.sp, fontWeight = FontWeight.Bold, @@ -500,7 +505,7 @@ private fun RoutingStatusRow(isRouting: Boolean) { ) } else { Text( - text = "! Last leg couldn't be routed - straight line used", + text = stringResource(R.string.builder_route_failed), color = TrackProTheme.colors.deltaBad, fontSize = 11.sp, fontWeight = FontWeight.Bold, @@ -516,7 +521,7 @@ private fun ModeToggle(selected: Int, onSelect: (Int) -> Unit) { modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Spacing.sm) ) { - val modes = listOf("Live GPS", "Manual Map") + val modes = listOf(stringResource(R.string.builder_live_gps), stringResource(R.string.builder_manual_map)) modes.forEachIndexed { index, label -> ToggleChip( text = label, @@ -580,8 +585,8 @@ private fun LiveControls(isRecording: Boolean, onToggle: () -> Unit) { // Recording is the one state on this screen that must be unmistakable while you are // walking a circuit, so it takes the danger accent rather than a label change alone. DashAction( - label = if (isRecording) "Stop recording" else "Start GPS recording", - detail = if (isRecording) "Recording \u00b7 walk the circuit" else null, + label = if (isRecording) stringResource(R.string.builder_stop_recording) else stringResource(R.string.builder_start_recording), + detail = if (isRecording) stringResource(R.string.builder_recording_walk) else null, onClick = onToggle, compact = true, haptic = Haptic.Confirm, @@ -597,14 +602,14 @@ private fun ManualControls(onUndo: () -> Unit, onSave: () -> Unit, canSave: Bool horizontalArrangement = Arrangement.spacedBy(Spacing.sm) ) { DashAction( - label = "Undo last", + label = stringResource(R.string.builder_undo), onClick = onUndo, compact = true, accent = TrackProTheme.colors.markingDim, modifier = Modifier.weight(1f) ) DashAction( - label = "Save track", + label = stringResource(R.string.builder_save), onClick = onSave, enabled = canSave, compact = true, @@ -627,40 +632,40 @@ fun TrackInfoAlert( AlertDialog( onDismissRequest = onDismiss, containerColor = TrackProTheme.colors.bgCard, - title = { Text("Track Details", color = TrackProTheme.colors.textPrimary) }, + title = { Text(stringResource(R.string.builder_details), color = TrackProTheme.colors.textPrimary) }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { TextField( value = name, onValueChange = { name = it }, - label = { Text("Track Name") }, + label = { Text(stringResource(R.string.builder_track_name)) }, modifier = Modifier.fillMaxWidth() ) TextField( value = country, onValueChange = { country = it }, - label = { Text("Country") }, + label = { Text(stringResource(R.string.builder_country)) }, modifier = Modifier.fillMaxWidth() ) - Text("Timing Mode", color = TrackProTheme.colors.textMuted, fontSize = 12.sp) + Text(stringResource(R.string.builder_timing_mode), color = TrackProTheme.colors.textMuted, fontSize = 12.sp) Row(verticalAlignment = Alignment.CenterVertically) { RadioButton(selected = mode == "Circuit", onClick = { mode = "Circuit" }) - Text("Circuit", color = TrackProTheme.colors.textPrimary) + Text(stringResource(R.string.track_type_circuit), color = TrackProTheme.colors.textPrimary) Spacer(Modifier.width(16.dp)) RadioButton(selected = mode == "Sprint", onClick = { mode = "Sprint" }) - Text("Sprint", color = TrackProTheme.colors.textPrimary) + Text(stringResource(R.string.track_type_sprint), color = TrackProTheme.colors.textPrimary) } } }, confirmButton = { TextButton(onClick = { onConfirm(name, country, mode) }) { - Text("DONE", color = TrackProTheme.colors.accent, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.common_done), color = TrackProTheme.colors.accent, fontWeight = FontWeight.Bold) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text("CANCEL", color = TrackProTheme.colors.textMuted) + Text(stringResource(R.string.common_cancel), color = TrackProTheme.colors.textMuted) } } ) diff --git a/app/src/main/java/com/example/trackpro/screens/TrackScreen.kt b/app/src/main/java/com/example/trackpro/screens/TrackScreen.kt index 16a6bad..528cf29 100644 --- a/app/src/main/java/com/example/trackpro/screens/TrackScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/TrackScreen.kt @@ -1,5 +1,7 @@ package com.example.trackpro.screens +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -16,6 +18,11 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import com.example.trackpro.components.localizedCountry +import com.example.trackpro.components.trackTypeLabel +import com.example.trackpro.theme.TrackProShapes +import com.example.trackpro.components.paddockCard +import androidx.compose.ui.draw.clip import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -126,7 +133,7 @@ fun TrackView( Column(modifier = Modifier.fillMaxSize()) { AppTopBar( - title = "Track Overview", + title = stringResource(R.string.track_title), accent = TrackProTheme.colors.accent, onBack = onBack ) @@ -135,8 +142,9 @@ fun TrackView( Column( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.bgCard) - .padding(horizontal = Spacing.lg, vertical = Spacing.md), + .padding(horizontal = Spacing.gutter) + .paddockCard() + .padding(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.xs) ) { Text( @@ -145,36 +153,42 @@ fun TrackView( color = TrackProTheme.colors.textPrimary ) Text( - text = "${trackInfo.value.country} · ${trackInfo.value.type}", - style = TrackProType.body, + text = "${localizedCountry(trackInfo.value.country)} · ${trackTypeLabel(trackInfo.value.type)}", + style = TrackProType.label, color = TrackProTheme.colors.textMuted ) - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) Row( - modifier = Modifier.fillMaxWidth().padding(top = Spacing.xs), + modifier = Modifier + .fillMaxWidth() + .padding(top = Spacing.md) + .clip(TrackProShapes.control) + .background(TrackProTheme.colors.bgElevated) + .padding(horizontal = Spacing.lg, vertical = Spacing.md), horizontalArrangement = Arrangement.SpaceBetween ) { StatCell( - label = "Length", + label = stringResource(R.string.tracks_length), // totalLength is stored in km; formatDistance takes meters, so convert // first. This also makes the unit dynamic instead of a hardcoded "km". value = trackInfo.value.totalLength?.let { UnitFormatter.formatDistance(it * 1000.0, useMetric) } ?: "?", horizontalAlignment = Alignment.CenterHorizontally ) StatCell( - label = "Type", - value = trackInfo.value.type.uppercase(), + label = stringResource(R.string.track_type), + value = trackTypeLabel(trackInfo.value.type), horizontalAlignment = Alignment.CenterHorizontally ) + // The number of stored GPS points - it was labelled "Corners", which it + // never was. StatCell( - label = "Corners", + label = stringResource(R.string.track_points), value = "${trackParts.size}", horizontalAlignment = Alignment.CenterHorizontally ) } } - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) + Spacer(Modifier.height(Spacing.md)) // ── Sectors card ──────────────────────────────────── if (trackParts.isNotEmpty()) { @@ -193,17 +207,20 @@ fun TrackView( } } ) - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) + Spacer(Modifier.height(Spacing.md)) } - TrackOnlineStrip(trackId = trackId, onOpenLeaderboard = onOpenLeaderboard) - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) + Box(Modifier.padding(horizontal = Spacing.gutter).paddockCard()) { + TrackOnlineStrip(trackId = trackId, onOpenLeaderboard = onOpenLeaderboard) + } // ── Map ─────────────────────────────────────────── Box( modifier = Modifier .fillMaxWidth() .weight(1f) + .padding(Spacing.gutter) + .clip(TrackProShapes.card) .background(TrackProTheme.colors.bgCard) ) { if (trackParts.isNotEmpty()) { @@ -217,7 +234,7 @@ fun TrackView( Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( - "READING TRACK", + stringResource(R.string.track_reading), style = TrackProType.label, color = TrackProTheme.colors.markingDim ) @@ -234,8 +251,8 @@ fun TrackView( } } else { EmptyState( - message = "No geometry for this track", - hint = "It was saved without coordinates. Rebuild it in the track builder to see the map and record sectors." + message = stringResource(R.string.track_no_geometry), + hint = stringResource(R.string.track_no_geometry_hint) ) } } @@ -260,19 +277,20 @@ private fun SectorSlicerCard( Column( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.bgCard) - .padding(horizontal = Spacing.lg, vertical = Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.sm) + .padding(horizontal = Spacing.gutter) + .paddockCard() + .padding(Spacing.lg), + verticalArrangement = Arrangement.spacedBy(Spacing.md) ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - SectionLabel("Sectors") + SectionLabel(stringResource(R.string.track_sectors)) Text( - text = if (sectorCount > 0) "$sectorCount marked" else "None marked", - style = TrackProType.body.atSize(11.sp), + text = if (sectorCount > 0) stringResource(R.string.track_sectors_marked, sectorCount) else stringResource(R.string.track_sectors_none), + style = TrackProType.label, color = if (sectorCount > 0) TrackProTheme.colors.accent else TrackProTheme.colors.textMuted ) } @@ -294,7 +312,7 @@ private fun SectorSlicerCard( if (sectorCount > 0) { Text( - text = "Clear sectors", + text = stringResource(R.string.track_clear_sectors), style = TrackProType.label, color = TrackProTheme.colors.danger, modifier = Modifier diff --git a/app/src/main/java/com/example/trackpro/screens/TrackVehicleSelectorScreen.kt b/app/src/main/java/com/example/trackpro/screens/TrackVehicleSelectorScreen.kt index 31ead19..7358a01 100644 --- a/app/src/main/java/com/example/trackpro/screens/TrackVehicleSelectorScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/TrackVehicleSelectorScreen.kt @@ -1,5 +1,7 @@ package com.example.trackpro.screens +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -15,6 +17,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import com.example.trackpro.theme.TrackProShapes +import androidx.compose.ui.draw.clip +import androidx.compose.material3.Icon +import androidx.compose.material.icons.outlined.Circle +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.Icons +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.Arrangement import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -36,7 +47,6 @@ import com.example.trackpro.extrasForUI.TrackProTheme import com.example.trackpro.components.AppTopBar import com.example.trackpro.components.DashAction import com.example.trackpro.components.DashGroup -import com.example.trackpro.components.SegmentBar import com.example.trackpro.theme.marking import com.example.trackpro.theme.markingDim import com.example.trackpro.theme.panel @@ -68,9 +78,8 @@ fun TrackVehicleSelectorScreen( vehicleViewModel.fetchVehicles() } - // Two conditions arm the session. The strip below reads them the way a dash reads - // any other pre-condition: how many of the required inputs are satisfied, drawn - // rather than described, so the gap is visible before you reach for the control. + // Two conditions arm the session. The checklist above the start button names them, so + // the gap is visible before you reach for the control. val trackSet = selectedTrackId != -1L val vehicleSet = selectedVehicleId != -1L val readyCount = listOf(trackSet, vehicleSet).count { it } @@ -82,21 +91,21 @@ fun TrackVehicleSelectorScreen( .background(TrackProTheme.colors.panel) ) { AppTopBar( - title = "Session setup", + title = stringResource(R.string.setup_title), accent = TrackProTheme.colors.accent, onBack = { navController.popBackStack() } ) - DashGroup("Circuit") { + DashGroup(stringResource(R.string.mode_track)) { Text( - text = selectedTrackName.ifEmpty { "Not selected" }, - style = TrackProType.statValue.atSize(22.sp), + text = selectedTrackName.ifEmpty { stringResource(R.string.setup_not_selected) }, + style = TrackProType.titleLarge, color = if (trackSet) TrackProTheme.colors.marking else TrackProTheme.colors.markingDim ) Spacer(Modifier.height(Spacing.sm)) TrackDropdownMenu( - label = "Choose location", + label = stringResource(R.string.setup_choose_track), tracks = tracks, selectedTrackName = selectedTrackName, onTrackSelected = { id -> @@ -107,17 +116,17 @@ fun TrackVehicleSelectorScreen( ) } - DashGroup("Vehicle") { + DashGroup(stringResource(R.string.car_title)) { Text( - text = selectedVehicleName.ifEmpty { "Not selected" }, - style = TrackProType.statValue.atSize(22.sp), + text = selectedVehicleName.ifEmpty { stringResource(R.string.setup_not_selected) }, + style = TrackProType.titleLarge, color = if (vehicleSet) TrackProTheme.colors.marking else TrackProTheme.colors.markingDim ) Spacer(Modifier.height(Spacing.sm)) if (vehicles.isNotEmpty()) { DropdownMenuFieldMulti( - "Choose machine", + stringResource(R.string.setup_choose_car), vehicles, selectedVehicleName ) { id -> @@ -127,8 +136,8 @@ fun TrackVehicleSelectorScreen( } } else { Text( - "No vehicles in the garage yet", - style = TrackProType.body.atSize(12.sp), + stringResource(R.string.setup_no_cars), + style = TrackProType.label, color = TrackProTheme.colors.deltaBad ) } @@ -136,40 +145,52 @@ fun TrackVehicleSelectorScreen( Spacer(Modifier.weight(1f)) - // -- Arming strip --------------------------------- + // -- Ready check + start ---------------------------- Column( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.panel) - .padding(horizontal = 12.dp, vertical = 12.dp) + .padding(Spacing.gutter) ) { - Row(verticalAlignment = Alignment.CenterVertically) { - SegmentBar( - signedFraction = readyCount / 2f, - activeColor = if (canStart) TrackProTheme.colors.deltaGood - else TrackProTheme.colors.deltaBad, - bidirectional = false, - segments = 2, - height = 10.dp, - modifier = Modifier.weight(1f) - ) - Spacer(Modifier.width(12.dp)) - Text( - text = "$readyCount / 2 SET", - style = TrackProType.label, - color = if (canStart) TrackProTheme.colors.deltaGood - else TrackProTheme.colors.markingDim - ) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + ReadyChip(stringResource(R.string.setup_track_set), stringResource(R.string.setup_track_needed), trackSet) + ReadyChip(stringResource(R.string.setup_car_set), stringResource(R.string.setup_car_needed), vehicleSet) } - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(Spacing.md)) DashAction( - label = "Start session", - detail = if (canStart) "Arm timing and go green" - else if (!trackSet) "Select a circuit first" else "Select a vehicle first", + label = stringResource(R.string.setup_start), + detail = if (canStart) stringResource(R.string.setup_start_ready) + else if (!trackSet) stringResource(R.string.setup_pick_track) else stringResource(R.string.setup_pick_car), enabled = canStart, - labelSize = 28.sp, + primary = true, + icon = Icons.Filled.Flag, onClick = { navController.navigate("timeattack/$selectedVehicleId/$selectedTrackId") } ) } } } + +/** One precondition for the start button: ticked when met, quiet when not. */ +@Composable +private fun ReadyChip(setLabel: String, neededLabel: String, ready: Boolean) { + val tint = if (ready) TrackProTheme.colors.deltaGood else TrackProTheme.colors.markingDim + Row( + modifier = Modifier + .clip(TrackProShapes.pill) + .background(if (ready) tint.copy(alpha = 0.14f) else TrackProTheme.colors.bgElevated) + .padding(horizontal = Spacing.md, vertical = Spacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + if (ready) Icons.Filled.CheckCircle else Icons.Outlined.Circle, + contentDescription = null, + tint = tint, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(Spacing.sm)) + Text( + if (ready) setLabel else neededLabel, + style = TrackProType.label, + color = if (ready) tint else TrackProTheme.colors.textMuted + ) + } +} diff --git a/app/src/main/java/com/example/trackpro/screens/drive/DriveScreen.kt b/app/src/main/java/com/example/trackpro/screens/drive/DriveScreen.kt new file mode 100644 index 0000000..2f91364 --- /dev/null +++ b/app/src/main/java/com/example/trackpro/screens/drive/DriveScreen.kt @@ -0,0 +1,401 @@ +package com.example.trackpro.screens.drive + +import com.example.trackpro.components.pluralResource +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.Timer +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.example.trackpro.TrackProApp +import com.example.trackpro.components.IconCircle +import com.example.trackpro.components.PaddockCard +import com.example.trackpro.components.PhotoFrame +import com.example.trackpro.components.paddockCard +import com.example.trackpro.components.pressable +import com.example.trackpro.dao.VehicleUsage +import com.example.trackpro.dataClasses.VehicleInformationData +import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.managerClasses.utilities.timed +import com.example.trackpro.managerClasses.utilities.toLapTimeString +import com.example.trackpro.online.AccountState +import com.example.trackpro.theme.Spacing +import com.example.trackpro.theme.TrackProShapes +import com.example.trackpro.theme.TrackProType +import com.example.trackpro.theme.accentGlow +import com.example.trackpro.theme.marking +import com.example.trackpro.theme.markingDim +import com.example.trackpro.theme.panel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import java.util.Calendar + +/** What the last session did, resolved off the main thread. */ +private data class LastSession( + val trackName: String?, + val bestLapMs: Long?, + val laps: Int, + /** Against your previous session on the same track; negative is faster. */ + val deltaToPreviousMs: Long?, +) + +/** + * The Drive tab: the app's home. + * + * Read parked, before a session: who you are, whether the rig is talking, which car you are + * in, and the two ways to start. The last session sits below as the thing you are trying to + * beat. Track is the one orange element - it is what most visits are for. + */ +@Composable +fun DriveScreen( + onStartTrack: () -> Unit, + onStartDrag: () -> Unit, + onOpenRig: () -> Unit, + onOpenHistory: (section: String) -> Unit, + onOpenCar: (Long) -> Unit, + onAddCar: () -> Unit, +) { + val context = LocalContext.current + val app = context.applicationContext as TrackProApp + + val isConnected by app.gpsManager.connectionStatus.collectAsState(initial = false) + val vehicles by app.database.vehicleInformationDAO().getAllVehicles().collectAsState(initial = emptyList()) + val usage by remember { app.database.vehicleInformationDAO().observeUsage() }.collectAsState(initial = emptyList()) + val sessions by app.database.sessionDataDao().getAllSessions().collectAsState(initial = emptyList()) + val account by app.online.auth.state.collectAsState() + + var last by remember { mutableStateOf(null) } + LaunchedEffect(sessions) { + val counted = sessions.filter { !it.voided } + val recent = counted.maxByOrNull { it.startTime } ?: run { last = null; return@LaunchedEffect } + last = withContext(Dispatchers.IO) { + val laps = app.database.lapTimeDataDAO().getLapsForSession(recent.id).timed() + val best = laps.minOfOrNull { it.millis } + val previous = counted + .filter { it.trackId != null && it.trackId == recent.trackId && it.startTime < recent.startTime } + .maxByOrNull { it.startTime } + val previousBest = previous?.let { p -> + app.database.lapTimeDataDAO().getLapsForSession(p.id).timed().minOfOrNull { it.millis } + } + LastSession( + trackName = recent.trackId?.takeIf { it != -1L }?.let { id -> + runCatching { app.database.trackMainDao().getTrack(id).first().trackName }.getOrNull() + }, + bestLapMs = best, + laps = laps.size, + deltaToPreviousMs = if (best != null && previousBest != null) best - previousBest else null, + ) + } + } + + // The car you drive most, not whichever row happens to be first in the table. + val mainId = usage.maxWithOrNull(compareBy({ it.sessions }, { it.lastUsed ?: 0L }))?.vehicleId + val vehicle = vehicles.firstOrNull { it.vehicleId == mainId } ?: vehicles.firstOrNull() + val name = (account as? AccountState.SignedIn)?.displayName ?: stringResource(R.string.drive_driver) + + Column( + modifier = Modifier + .fillMaxSize() + .background(TrackProTheme.colors.panel) + .statusBarsPadding() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.gutter) + ) { + Spacer(Modifier.height(Spacing.xl)) + Greeting(name = name, isConnected = isConnected, onOpenRig = onOpenRig) + + Spacer(Modifier.height(Spacing.xl)) + CarCard(vehicle = vehicle, photo = { app.online.photos.file(it) }, onOpenCar = onOpenCar, onAddCar = onAddCar) + + Spacer(Modifier.height(Spacing.md)) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.md)) { + ModeTile( + title = stringResource(R.string.mode_track), + detail = stringResource(R.string.drive_track_detail), + icon = Icons.Default.Flag, + primary = true, + onClick = onStartTrack, + modifier = Modifier.weight(1f) + ) + ModeTile( + title = stringResource(R.string.mode_drag), + detail = stringResource(R.string.drive_drag_detail), + icon = Icons.Default.Bolt, + primary = false, + onClick = onStartDrag, + modifier = Modifier.weight(1f) + ) + } + + Spacer(Modifier.height(Spacing.xl)) + Text(stringResource(R.string.drive_last_session), style = TrackProType.titleMedium, color = TrackProTheme.colors.marking) + Spacer(Modifier.height(Spacing.sm)) + LastSessionCard(last = last, onOpen = { onOpenHistory("track") }) + + Spacer(Modifier.height(Spacing.md)) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.md)) { + CountCard( + label = stringResource(R.string.drive_track_sessions), + value = sessions.count { it.trackId != null && it.trackId != -1L && !it.voided }, + icon = Icons.Default.Timer, + onClick = { onOpenHistory("track") }, + modifier = Modifier.weight(1f) + ) + CountCard( + label = stringResource(R.string.drive_drag_runs), + value = sessions.count { (it.trackId == null || it.trackId == -1L) && !it.voided }, + icon = Icons.Default.Bolt, + onClick = { onOpenHistory("drag") }, + modifier = Modifier.weight(1f) + ) + } + Spacer(Modifier.height(Spacing.xl)) + } +} + +@Composable +private fun Greeting(name: String, isConnected: Boolean, onOpenRig: () -> Unit) { + val hour = remember { Calendar.getInstance().get(Calendar.HOUR_OF_DAY) } + val greeting = when (hour) { + in 5..11 -> stringResource(R.string.drive_good_morning) + in 12..17 -> stringResource(R.string.drive_good_afternoon) + else -> stringResource(R.string.drive_good_evening) + } + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(greeting, style = TrackProType.body, color = TrackProTheme.colors.markingDim) + Text( + name, + style = TrackProType.titleLarge, + color = TrackProTheme.colors.marking, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + RigPill(isConnected = isConnected, onClick = onOpenRig) + } +} + +/** + * The rig's state as a status pill. No signal is red, never dimmer than healthy: a driver + * about to start a session needs to see it. + */ +@Composable +private fun RigPill(isConnected: Boolean, onClick: () -> Unit) { + val tone = if (isConnected) TrackProTheme.colors.deltaGood else TrackProTheme.colors.danger + Row( + modifier = Modifier + .pressable(onClick = onClick, scale = 0.95f) + .heightIn(min = 40.dp) + .clip(TrackProShapes.pill) + .background(tone.copy(alpha = 0.14f)) + .padding(horizontal = Spacing.md, vertical = Spacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Box(Modifier.size(8.dp).clip(CircleShape).background(tone)) + Spacer(Modifier.width(Spacing.sm)) + Text( + if (isConnected) stringResource(R.string.drive_rig_linked) else stringResource(R.string.drive_no_signal), + style = TrackProType.label.copy(fontWeight = FontWeight.SemiBold), + color = tone + ) + } +} + +@Composable +private fun CarCard( + vehicle: VehicleInformationData?, + photo: (String?) -> java.io.File?, + onOpenCar: (Long) -> Unit, + onAddCar: () -> Unit, +) { + PaddockCard( + onClick = { if (vehicle != null) onOpenCar(vehicle.vehicleId) else onAddCar() }, + padding = Spacing.md + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (vehicle != null) { + PhotoFrame( + file = photo(vehicle.photoFile), + contentDescription = "${vehicle.manufacturer} ${vehicle.model}", + modifier = Modifier.size(width = 72.dp, height = 56.dp) + ) + } else { + IconCircle(Icons.Default.Add, size = 56.dp) + } + Spacer(Modifier.width(Spacing.lg)) + Column(Modifier.weight(1f)) { + Text( + text = vehicle?.let { "${it.manufacturer} ${it.model}" } ?: stringResource(R.string.drive_add_car), + style = TrackProType.titleMedium, + color = TrackProTheme.colors.marking, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = vehicle?.let { v -> + listOfNotNull( + v.horsepower.takeIf { it > 0 }?.let { "$it hp" }, + v.drivetrain.takeIf { it.isNotBlank() }, + v.weight.takeIf { it > 0 }?.let { "${it.toInt()} kg" }, + ).joinToString(" · ") + } ?: stringResource(R.string.drive_add_car_hint), + style = TrackProType.label, + color = TrackProTheme.colors.markingDim, + maxLines = 1 + ) + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = TrackProTheme.colors.markingDim + ) + } + } +} + +/** + * A way to start. The primary tile is filled orange with a soft glow of its own colour; the + * secondary is a card with the accent only in its icon. + */ +@Composable +private fun ModeTile( + title: String, + detail: String, + icon: ImageVector, + primary: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val glow = TrackProTheme.colors.accentGlow + val ink = if (primary) TrackProTheme.colors.onAccent else TrackProTheme.colors.marking + Column( + modifier = modifier + .height(148.dp) + .pressable(onClick = onClick, scale = 0.96f) + .then( + if (primary) Modifier + .shadow(20.dp, TrackProShapes.card, clip = false, ambientColor = glow, spotColor = glow) + .clip(TrackProShapes.card) + .background(TrackProTheme.colors.accent) + else Modifier.paddockCard() + ) + .padding(Spacing.lg), + verticalArrangement = Arrangement.SpaceBetween + ) { + IconCircle(icon = icon, tint = if (primary) TrackProTheme.colors.onAccent else TrackProTheme.colors.accent, size = 44.dp) + Column { + Text(title, style = TrackProType.titleLarge, color = ink) + Text( + detail, + style = TrackProType.label, + color = if (primary) ink.copy(alpha = 0.72f) else TrackProTheme.colors.markingDim, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun LastSessionCard(last: LastSession?, onOpen: () -> Unit) { + PaddockCard(onClick = onOpen) { + if (last?.bestLapMs == null) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconCircle(Icons.Default.EmojiEvents, size = 48.dp) + Spacer(Modifier.width(Spacing.lg)) + Column { + Text(stringResource(R.string.drive_no_laps), style = TrackProType.titleMedium, color = TrackProTheme.colors.marking) + Text( + stringResource(R.string.drive_no_laps_hint), + style = TrackProType.label, + color = TrackProTheme.colors.markingDim + ) + } + } + } else { + Row(verticalAlignment = Alignment.Top) { + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.drive_best_lap_at, last.trackName ?: stringResource(R.string.drive_last_session_fallback)), + style = TrackProType.label, + color = TrackProTheme.colors.markingDim, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.height(4.dp)) + Text(last.bestLapMs.toLapTimeString(), style = TrackProType.displayNumeric, color = TrackProTheme.colors.marking) + } + Text( + pluralResource(R.plurals.count_laps, last.laps), + style = TrackProType.label, + color = TrackProTheme.colors.markingDim, + modifier = Modifier + .clip(TrackProShapes.pill) + .background(TrackProTheme.colors.bgElevated) + .padding(horizontal = Spacing.md, vertical = 6.dp) + ) + } + last.deltaToPreviousMs?.let { delta -> + Spacer(Modifier.height(Spacing.sm)) + val better = delta < 0 + val tone: Color = if (better) TrackProTheme.colors.deltaGood else TrackProTheme.colors.deltaBad + Text( + text = stringResource(R.string.drive_vs_previous, String.format(java.util.Locale.US, "%+.2f", delta / 1000.0)), + style = TrackProType.label.copy(fontWeight = FontWeight.SemiBold), + color = tone + ) + } + } + } +} + +@Composable +private fun CountCard(label: String, value: Int, icon: ImageVector, onClick: () -> Unit, modifier: Modifier = Modifier) { + PaddockCard(onClick = onClick, modifier = modifier, padding = Spacing.lg) { + IconCircle(icon = icon, size = 36.dp) + Spacer(Modifier.height(Spacing.md)) + Text("$value", style = TrackProType.titleLarge, color = TrackProTheme.colors.marking) + Text(label, style = TrackProType.label, color = TrackProTheme.colors.markingDim) + } +} diff --git a/app/src/main/java/com/example/trackpro/screens/garage/CarSections.kt b/app/src/main/java/com/example/trackpro/screens/garage/CarSections.kt index 664df00..ffd1310 100644 --- a/app/src/main/java/com/example/trackpro/screens/garage/CarSections.kt +++ b/app/src/main/java/com/example/trackpro/screens/garage/CarSections.kt @@ -1,5 +1,8 @@ package com.example.trackpro.screens.garage +import com.example.trackpro.components.pluralResource +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -16,6 +19,16 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import com.example.trackpro.online.MessageText +import com.example.trackpro.theme.TrackProShapes +import com.example.trackpro.theme.Spacing +import com.example.trackpro.components.pressable +import com.example.trackpro.components.SectionTitle +import com.example.trackpro.components.PaddockCard +import com.example.trackpro.components.DashAction +import androidx.compose.material.icons.filled.DeleteOutline +import androidx.compose.material.icons.Icons +import androidx.compose.ui.draw.clip import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -76,12 +89,13 @@ fun CarPhotoSection(vehicle: VehicleInformationData) { } } + val unreadable = stringResource(R.string.photo_unreadable) val picker = rememberPhotoPicker { uri -> busy = true error = null scope.launch { val name = app.online.photos.import(uri, "vehicle-${vehicle.vehicleId}") - if (name == null) error = "That file could not be read as a picture." else replacePhoto(name) + if (name == null) error = unreadable else replacePhoto(name) busy = false } } @@ -89,23 +103,23 @@ fun CarPhotoSection(vehicle: VehicleInformationData) { PhotoFrame( file = app.online.photos.file(vehicle.photoFile), contentDescription = "${vehicle.manufacturer} ${vehicle.model}", - modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f) + shape = TrackProShapes.card, + modifier = Modifier.padding(horizontal = Spacing.gutter).fillMaxWidth().aspectRatio(16f / 10f) ) Row( - modifier = Modifier.fillMaxWidth().background(TrackProTheme.colors.panel).padding(horizontal = 12.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.gutter, vertical = Spacing.md), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalAlignment = Alignment.CenterVertically ) { - ToggleChip(text = "Take photo", selected = false, onClick = { if (!busy) picker.fromCamera() }, modifier = Modifier.weight(1f)) - ToggleChip(text = "Choose", selected = false, onClick = { if (!busy) picker.fromGallery() }, modifier = Modifier.weight(1f)) + ToggleChip(text = stringResource(R.string.photo_take), selected = false, onClick = { if (!busy) picker.fromCamera() }, modifier = Modifier.weight(1f)) + ToggleChip(text = stringResource(R.string.photo_choose), selected = false, onClick = { if (!busy) picker.fromGallery() }, modifier = Modifier.weight(1f)) if (vehicle.photoFile != null) { - ToggleChip(text = "Remove", selected = false, onClick = { if (!busy) replacePhoto(null) }, modifier = Modifier.weight(1f)) + ToggleChip(text = stringResource(R.string.photo_remove), selected = false, onClick = { if (!busy) replacePhoto(null) }, modifier = Modifier.weight(1f)) } } error?.let { - Text(it.uppercase(), style = TrackProType.label, color = TrackProTheme.colors.deltaBad, modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp)) + Text(it, style = TrackProType.label, color = TrackProTheme.colors.deltaBad, modifier = Modifier.padding(horizontal = Spacing.gutter)) } - Bezel() } /** @@ -124,28 +138,28 @@ fun CarBackupStrip(vehicle: VehicleInformationData, onSignIn: () -> Unit) { val own = link.firstOrNull { it.localId == vehicle.vehicleId } val status = vehicleSyncStatus(vehicle, own, signedIn) + val tone = statusLamp(status) ?: TrackProTheme.colors.accent Row( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.field) - .then(if (!signedIn) Modifier.pressableRow(onClick = onSignIn) else Modifier) + .then(if (!signedIn) Modifier.pressable(onClick = onSignIn, scale = 0.98f) else Modifier) + .clip(TrackProShapes.control) + .background(tone.copy(alpha = 0.10f)) .heightIn(min = 48.dp) - .padding(horizontal = 14.dp, vertical = 10.dp), + .padding(horizontal = Spacing.lg, vertical = Spacing.md), verticalAlignment = Alignment.CenterVertically ) { - statusLamp(status)?.let { lamp -> - Box(Modifier.size(6.dp).background(lamp, CircleShape)) - Spacer(Modifier.width(8.dp)) - } + Box(Modifier.size(8.dp).clip(CircleShape).background(tone)) + Spacer(Modifier.width(Spacing.md)) Column(Modifier.weight(1f)) { Text( - status.label.uppercase(), + stringResource(status.label), style = TrackProType.label, color = if (status == VehicleSyncStatus.Failed) TrackProTheme.colors.danger else TrackProTheme.colors.marking ) val detail = when (status) { - VehicleSyncStatus.OnThisPhone -> "Sign in to back it up" - VehicleSyncStatus.Failed, VehicleSyncStatus.LocalOnly -> own?.lastError + VehicleSyncStatus.OnThisPhone -> stringResource(R.string.car_sign_in_to_backup) + VehicleSyncStatus.Failed, VehicleSyncStatus.LocalOnly -> MessageText.localize(own?.lastError) else -> null } detail?.let { @@ -154,9 +168,9 @@ fun CarBackupStrip(vehicle: VehicleInformationData, onSignIn: () -> Unit) { } } when (status) { - VehicleSyncStatus.OnThisPhone -> Text("SIGN IN ›", style = TrackProType.label, color = TrackProTheme.colors.accent) + VehicleSyncStatus.OnThisPhone -> Text(stringResource(R.string.common_sign_in), style = TrackProType.label, color = TrackProTheme.colors.accent) VehicleSyncStatus.LocalOnly, VehicleSyncStatus.Failed -> ToggleChip( - text = if (status == VehicleSyncStatus.LocalOnly) "Back up again" else "Retry", + text = if (status == VehicleSyncStatus.LocalOnly) stringResource(R.string.car_back_up_again) else stringResource(R.string.common_retry), selected = false, onClick = { app.applicationScope.launch(Dispatchers.IO) { @@ -171,7 +185,6 @@ fun CarBackupStrip(vehicle: VehicleInformationData, onSignIn: () -> Unit) { else -> Unit } } - Bezel() } /** This car's best lap on each track it has been driven on, from the phone's own records. */ @@ -186,36 +199,35 @@ fun CarBests(vehicleId: Long, onOpenTrack: (Long) -> Unit) { } }.collectAsState(initial = LocalCareer.EMPTY) - SectionLabel("Bests in this car", modifier = Modifier.padding(start = 16.dp, top = 18.dp, bottom = 8.dp)) - Bezel() - if (career.bests.isEmpty()) { - Text( - if (career.sessionCount == 0) "NOT DRIVEN ON A TRACK YET" else "NO TIMED LAPS YET", - style = TrackProType.label, - color = TrackProTheme.colors.markingDim, - modifier = Modifier.fillMaxWidth().background(TrackProTheme.colors.field).padding(14.dp) - ) - Bezel() - return - } - career.bests.forEach { best -> - Row( - modifier = Modifier - .fillMaxWidth() - .pressableRow(onClick = { onOpenTrack(best.trackId) }) - .background(TrackProTheme.colors.field) - .heightIn(min = 56.dp) - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text(best.trackName.uppercase(), style = TrackProType.titleMedium, color = TrackProTheme.colors.marking, maxLines = 1, overflow = TextOverflow.Ellipsis) - Spacer(Modifier.height(2.dp)) - Text("${best.lapCount} LAP${if (best.lapCount == 1) "" else "S"}", style = TrackProType.label, color = TrackProTheme.colors.markingDim) + SectionTitle(stringResource(R.string.car_bests)) + Column(Modifier.padding(horizontal = Spacing.gutter)) { + PaddockCard(padding = 0.dp) { + if (career.bests.isEmpty()) { + Text( + if (career.sessionCount == 0) stringResource(R.string.car_not_driven) else stringResource(R.string.car_no_timed_laps), + style = TrackProType.body, + color = TrackProTheme.colors.markingDim, + modifier = Modifier.fillMaxWidth().padding(Spacing.lg) + ) + } + career.bests.forEachIndexed { i, best -> + if (i > 0) Bezel(Modifier.padding(horizontal = Spacing.lg)) + Row( + modifier = Modifier + .fillMaxWidth() + .pressableRow(onClick = { onOpenTrack(best.trackId) }) + .heightIn(min = 56.dp) + .padding(horizontal = Spacing.lg, vertical = Spacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text(best.trackName, style = TrackProType.titleMedium, color = TrackProTheme.colors.marking, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(pluralResource(R.plurals.count_laps, best.lapCount), style = TrackProType.label, color = TrackProTheme.colors.markingDim) + } + Text(best.bestLapMs.toLapTimeString(), style = TrackProType.titleMedium, color = TrackProTheme.colors.marking) + } } - Text(best.bestLapMs.toLapTimeString(), style = TrackProType.statValue.atSize(18.sp), color = TrackProTheme.colors.marking) } - Bezel() } } @@ -230,10 +242,10 @@ fun CarDeleteRow(vehicle: VehicleInformationData, onDeleted: () -> Unit) { if (confirm) { ConfirmDeleteDialog( - title = "Delete ${vehicle.manufacturer} ${vehicle.model}?", - message = (if (sessions > 0) "Its $sessions recorded session${if (sessions == 1) "" else "s"} and their laps are deleted with it. " else "") + - (if (app.online.auth.isSignedIn) "It is also removed from your account. " else "") + - "This cannot be undone.", + title = stringResource(R.string.garage_delete_car_title, "${vehicle.manufacturer} ${vehicle.model}"), + message = (if (sessions > 0) pluralResource(R.plurals.garage_delete_sessions, sessions) else "") + + (if (app.online.auth.isSignedIn) stringResource(R.string.garage_delete_account) else "") + + stringResource(R.string.common_cannot_undo), onConfirm = { confirm = false app.applicationScope.launch(Dispatchers.IO) { @@ -247,19 +259,13 @@ fun CarDeleteRow(vehicle: VehicleInformationData, onDeleted: () -> Unit) { ) } - Spacer(Modifier.height(18.dp)) - Bezel() - Row( - modifier = Modifier - .fillMaxWidth() - .pressableRow(onClick = { confirm = true }) - .background(TrackProTheme.colors.field) - .heightIn(min = 56.dp) - .padding(horizontal = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text("DELETE CAR", style = TrackProType.titleMedium, color = TrackProTheme.colors.danger, modifier = Modifier.weight(1f)) - Text("›", style = TrackProType.titleLarge, color = TrackProTheme.colors.danger) + Column(Modifier.padding(horizontal = Spacing.gutter, vertical = Spacing.xl)) { + DashAction( + label = stringResource(R.string.car_delete), + onClick = { confirm = true }, + compact = true, + accent = TrackProTheme.colors.danger, + icon = Icons.Default.DeleteOutline + ) } - Bezel() } diff --git a/app/src/main/java/com/example/trackpro/screens/garage/GarageScreen.kt b/app/src/main/java/com/example/trackpro/screens/garage/GarageScreen.kt index 5745d4d..d2ac33b 100644 --- a/app/src/main/java/com/example/trackpro/screens/garage/GarageScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/garage/GarageScreen.kt @@ -1,5 +1,8 @@ package com.example.trackpro.screens.garage +import com.example.trackpro.components.pluralResource +import androidx.compose.ui.res.stringResource +import com.example.trackpro.R import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,6 +22,19 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import com.example.trackpro.theme.accentSoft +import com.example.trackpro.theme.TrackProShapes +import com.example.trackpro.theme.Spacing +import com.example.trackpro.components.PaddockCard +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.draw.clip +import androidx.compose.material3.Icon +import androidx.compose.material.icons.filled.ErrorOutline +import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.Icons import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -82,7 +98,7 @@ fun GarageScreen( var section by rememberSaveable { mutableStateOf(initial) } val header: @Composable () -> Unit = { SectionSwitch( - options = listOf(GarageSection.Cars to "Cars", GarageSection.Tracks to "Tracks"), + options = listOf(GarageSection.Cars to stringResource(R.string.garage_cars), GarageSection.Tracks to stringResource(R.string.garage_tracks)), selected = section, onSelect = { section = it } ) @@ -95,7 +111,7 @@ fun GarageScreen( onBack = null, header = header, onBuildTrack = { navController.navigate("trackbuilder") }, - title = "Garage", + title = stringResource(R.string.garage_title), ) } } @@ -138,16 +154,12 @@ private fun GarageCars( pendingDelete?.let { vehicle -> val sessions = usageById[vehicle.vehicleId]?.sessions ?: 0 ConfirmDeleteDialog( - title = "Delete ${vehicle.manufacturer} ${vehicle.model}?", - message = buildString { - // Deleting a car cascades to its sessions in the database. Say so, with the count. - append( - if (sessions > 0) "Its $sessions recorded session${if (sessions == 1) "" else "s"} and their laps are deleted with it. " - else "It has no recorded sessions. " - ) - if (signedIn) append("It is also removed from your account.") - append(" This cannot be undone.") - }, + title = stringResource(R.string.garage_delete_car_title, "${vehicle.manufacturer} ${vehicle.model}"), + // Deleting a car cascades to its sessions in the database. Say so, with the count. + message = (if (sessions > 0) pluralResource(R.plurals.garage_delete_sessions, sessions) + else stringResource(R.string.garage_delete_no_sessions)) + + (if (signedIn) stringResource(R.string.garage_delete_account) else "") + + stringResource(R.string.common_cannot_undo), onConfirm = { pendingDelete = null app.applicationScope.launch(Dispatchers.IO) { @@ -161,33 +173,30 @@ private fun GarageCars( } ScreenScaffold( - title = "Garage", + title = stringResource(R.string.garage_title), header = header, trailing = { - Text(countLabel(vehicles.size, "car"), style = TrackProType.label, color = TrackProTheme.colors.markingDim) + Text(countLabel(vehicles.size, R.plurals.count_cars), style = TrackProType.label, color = TrackProTheme.colors.markingDim) }, contentScrolled = scrolled ) { contentPadding -> LazyColumn( modifier = Modifier.fillMaxSize().background(TrackProTheme.colors.panel), state = listState, - contentPadding = PaddingValues(top = contentPadding.calculateTopPadding(), bottom = 24.dp) + contentPadding = PaddingValues( + start = Spacing.gutter, + end = Spacing.gutter, + top = contentPadding.calculateTopPadding() + Spacing.xs, + bottom = Spacing.xl + ), + verticalArrangement = Arrangement.spacedBy(Spacing.md) ) { item { - Box(modifier = Modifier.padding(12.dp)) { - DashAction( - label = "Add car", - detail = "Every session is timed against one", - onClick = { navController.navigate("createvehicle") }, - compact = true - ) - } BackupStrip( signedIn = signedIn, statuses = statuses.values, onSignIn = { navController.navigate("account") } ) - Bezel() } if (ordered.isEmpty()) { @@ -195,16 +204,16 @@ private fun GarageCars( DataGate( state = loadState, items = ordered, - emptyMessage = "No cars yet", - emptyHint = "Add the car you drive and every lap is filed against it", - loadingLabel = "Reading garage", - modifier = Modifier.fillMaxWidth().height(240.dp) + emptyMessage = stringResource(R.string.garage_add_first), + emptyHint = stringResource(R.string.garage_add_first_hint), + loadingLabel = stringResource(R.string.garage_reading), + modifier = Modifier.fillMaxWidth().height(260.dp) ) { } } } items(ordered, key = { it.vehicleId }) { vehicle -> - CarRow( + CarCard( vehicle = vehicle, photo = app.online.photos.file(vehicle.photoFile), sessions = usageById[vehicle.vehicleId]?.sessions ?: 0, @@ -213,15 +222,23 @@ private fun GarageCars( onOpen = { navController.navigate("vehicle/${vehicle.vehicleId}") }, onLongPress = { pendingDelete = vehicle } ) - Bezel() + } + + item { + DashAction( + label = stringResource(R.string.garage_add_car), + onClick = { navController.navigate("createvehicle") }, + compact = true, + icon = Icons.Default.Add + ) } } } } /** - * One line that says whether the garage is safe. Signed out it is an invitation, and the - * whole strip is the way to act on it. + * Whether the garage is safe, as one small pill. Signed out it is an invitation, and the + * whole pill is the way to act on it. */ @Composable private fun BackupStrip( @@ -231,38 +248,33 @@ private fun BackupStrip( ) { val backedUp = statuses.count { it == VehicleSyncStatus.Synced } val failed = statuses.count { it == VehicleSyncStatus.Failed } - val (lamp, text) = when { - !signedIn -> null to "Not backed up · sign in to keep your garage" - failed > 0 -> TrackProTheme.colors.danger to "$failed car${if (failed == 1) "" else "s"} could not be backed up" - backedUp == statuses.size -> TrackProTheme.colors.deltaGood to "Garage backed up to your account" - else -> TrackProTheme.colors.deltaBad to "$backedUp of ${statuses.size} backed up · the rest go on the next sync" + val (tone, icon, text) = when { + !signedIn -> Triple(TrackProTheme.colors.accent, Icons.Default.CloudOff, stringResource(R.string.garage_not_backed_up)) + failed > 0 -> Triple(TrackProTheme.colors.danger, Icons.Default.ErrorOutline, pluralResource(R.plurals.garage_backup_failed_count, failed)) + backedUp == statuses.size -> Triple(TrackProTheme.colors.deltaGood, Icons.Default.CloudDone, stringResource(R.string.garage_backed_up)) + else -> Triple(TrackProTheme.colors.deltaBad, Icons.Default.CloudSync, stringResource(R.string.garage_backed_partial, backedUp, statuses.size)) } Row( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.field) - .then(if (!signedIn) Modifier.pressableRow(onClick = onSignIn) else Modifier) - .padding(horizontal = 14.dp, vertical = 12.dp), + .then(if (!signedIn) Modifier.pressable(onClick = onSignIn, scale = 0.98f) else Modifier) + .clip(TrackProShapes.control) + .background(tone.copy(alpha = 0.10f)) + .padding(horizontal = Spacing.lg, vertical = Spacing.md), verticalAlignment = Alignment.CenterVertically ) { - if (lamp != null) { - Box(Modifier.size(6.dp).background(lamp, CircleShape)) - Spacer(Modifier.width(8.dp)) - } - Text( - text = text.uppercase(), - style = TrackProType.label, - color = if (failed > 0 && signedIn) TrackProTheme.colors.danger else TrackProTheme.colors.markingDim, - modifier = Modifier.weight(1f) - ) + Icon(icon, contentDescription = null, tint = tone, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(Spacing.md)) + Text(text = text, style = TrackProType.label, color = TrackProTheme.colors.marking, modifier = Modifier.weight(1f)) if (!signedIn) { - Text("SIGN IN ›", style = TrackProType.label, color = TrackProTheme.colors.accent) + Spacer(Modifier.width(Spacing.sm)) + Text(stringResource(R.string.common_sign_in), style = TrackProType.label.copy(fontWeight = FontWeight.SemiBold), color = TrackProTheme.colors.accent) } } } @Composable -private fun CarRow( +private fun CarCard( vehicle: VehicleInformationData, photo: java.io.File?, sessions: Int, @@ -271,61 +283,63 @@ private fun CarRow( onOpen: () -> Unit, onLongPress: () -> Unit, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .pressable(onClick = onOpen, onLongClick = onLongPress, scale = 0.99f) - .background(TrackProTheme.colors.field) - .padding(horizontal = 12.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - PhotoFrame( - file = photo, - contentDescription = "${vehicle.manufacturer} ${vehicle.model}", - modifier = Modifier.size(width = 112.dp, height = 84.dp) - ) - Spacer(Modifier.width(14.dp)) - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - text = "${vehicle.manufacturer} ${vehicle.model}".uppercase(), - style = TrackProType.titleLarge.atSize(17.sp), - color = TrackProTheme.colors.marking, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false) - ) - if (isMain) { - Spacer(Modifier.width(8.dp)) - Text("MAIN", style = TrackProType.label, color = TrackProTheme.colors.accent) - } - } - Spacer(Modifier.height(3.dp)) - Text( - text = listOfNotNull( - vehicle.year.takeIf { it > 0 }?.toString(), - vehicle.horsepower.takeIf { it > 0 }?.let { "$it HP" }, - vehicle.drivetrain.takeIf { it.isNotBlank() }?.uppercase(), - ).joinToString(" · "), - style = TrackProType.label, - color = TrackProTheme.colors.markingDim, - maxLines = 1 + PaddockCard(onClick = onOpen, onLongClick = onLongPress, padding = Spacing.md) { + Row(verticalAlignment = Alignment.CenterVertically) { + PhotoFrame( + file = photo, + contentDescription = "${vehicle.manufacturer} ${vehicle.model}", + modifier = Modifier.size(width = 104.dp, height = 80.dp) ) - Spacer(Modifier.height(10.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - statusLamp(status)?.let { lamp -> - Box(Modifier.size(6.dp).background(lamp, CircleShape)) - Spacer(Modifier.width(6.dp)) + Spacer(Modifier.width(Spacing.lg)) + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "${vehicle.manufacturer} ${vehicle.model}", + style = TrackProType.titleMedium, + color = TrackProTheme.colors.marking, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + if (isMain) { + Spacer(Modifier.width(Spacing.sm)) + Text( + stringResource(R.string.garage_main), + style = TrackProType.label.copy(fontWeight = FontWeight.SemiBold), + color = TrackProTheme.colors.accent, + modifier = Modifier + .clip(TrackProShapes.pill) + .background(TrackProTheme.colors.accentSoft) + .padding(horizontal = Spacing.sm, vertical = 2.dp) + ) + } } Text( - text = "${status.label} · $sessions session${if (sessions == 1) "" else "s"}".uppercase(), + text = listOfNotNull( + vehicle.year.takeIf { it > 0 }?.toString(), + vehicle.horsepower.takeIf { it > 0 }?.let { "$it hp" }, + vehicle.drivetrain.takeIf { it.isNotBlank() }?.uppercase(), + ).joinToString(" · "), style = TrackProType.label, - // A failed backup is a fault; it is never drawn dimmer than a healthy one. - color = if (status == VehicleSyncStatus.Failed) TrackProTheme.colors.danger - else TrackProTheme.colors.markingDim, - maxLines = 1, - overflow = TextOverflow.Ellipsis + color = TrackProTheme.colors.markingDim, + maxLines = 1 ) + Spacer(Modifier.height(Spacing.sm)) + Row(verticalAlignment = Alignment.CenterVertically) { + statusLamp(status)?.let { lamp -> + Box(Modifier.size(8.dp).clip(CircleShape).background(lamp)) + Spacer(Modifier.width(6.dp)) + } + Text( + text = "${stringResource(status.label)} · ${pluralResource(R.plurals.count_sessions, sessions)}", + style = TrackProType.label, + // A failed backup is a fault; never drawn dimmer than a healthy one. + color = if (status == VehicleSyncStatus.Failed) TrackProTheme.colors.danger + else TrackProTheme.colors.markingDim, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } } diff --git a/app/src/main/java/com/example/trackpro/screens/history/HistoryScreen.kt b/app/src/main/java/com/example/trackpro/screens/history/HistoryScreen.kt index e89e0df..05f0a8a 100644 --- a/app/src/main/java/com/example/trackpro/screens/history/HistoryScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/history/HistoryScreen.kt @@ -1,5 +1,7 @@ package com.example.trackpro.screens.history +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -30,9 +32,13 @@ fun HistoryScreen( initial: HistorySection = HistorySection.Track, ) { var section by rememberSaveable(initial) { mutableStateOf(initial) } + // One filter per section, held here rather than in each list so switching between Track + // and Drag keeps both, and saved so opening a session and coming back keeps them too. + var trackFilter by rememberSaveable(stateSaver = SessionFilterSaver) { mutableStateOf(SessionFilter()) } + var dragFilter by rememberSaveable(stateSaver = SessionFilterSaver) { mutableStateOf(SessionFilter()) } val header: @Composable () -> Unit = { SectionSwitch( - options = listOf(HistorySection.Track to "Track", HistorySection.Drag to "Drag"), + options = listOf(HistorySection.Track to stringResource(R.string.mode_track), HistorySection.Drag to stringResource(R.string.mode_drag)), selected = section, onSelect = { section = it } ) @@ -45,14 +51,18 @@ fun HistoryScreen( vehicleViewModel = vehicleViewModel, onBack = null, header = header, - title = "History", + title = stringResource(R.string.history_title), + filter = trackFilter, + onFilterChange = { trackFilter = it }, ) HistorySection.Drag -> DragTimesListView( viewModel = dragSessionViewModel, navController = navController, onBack = null, header = header, - title = "History", + title = stringResource(R.string.history_title), + filter = dragFilter, + onFilterChange = { dragFilter = it }, ) } } diff --git a/app/src/main/java/com/example/trackpro/screens/history/SessionFilterBar.kt b/app/src/main/java/com/example/trackpro/screens/history/SessionFilterBar.kt new file mode 100644 index 0000000..abc47fb --- /dev/null +++ b/app/src/main/java/com/example/trackpro/screens/history/SessionFilterBar.kt @@ -0,0 +1,231 @@ +package com.example.trackpro.screens.history + +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.example.trackpro.components.Haptic +import com.example.trackpro.components.pressable +import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.theme.Spacing +import com.example.trackpro.theme.TrackProShapes +import com.example.trackpro.theme.TrackProType +import com.example.trackpro.theme.fieldLive +import com.example.trackpro.theme.marking +import com.example.trackpro.theme.markingDim +import com.example.trackpro.theme.panel + +/** Height of a [SessionFilterBar] including its margin. */ +val SessionFilterBarHeight = 56.dp + +/** One choice in a filter menu: an id to filter on and what to call it. */ +data class FilterOption(val id: Long, val label: String) + +/** + * The History list's filters, as a row of dropdown chips under the section switch: car, + * track, period, and order. + * + * A chip that is hiding something is lit in the accent, so a list that looks short always + * says why. A clear chip appears at the front of the row only then. The row scrolls sideways + * rather than wrapping, so the header keeps one fixed height and the list never jumps. + * + * [cars] and [tracks] should be only those the sessions actually use, so no choice leads to an + * empty list. [tracks] is null where a track filter makes no sense (drag runs). + */ +@Composable +fun SessionFilterBar( + filter: SessionFilter, + onChange: (SessionFilter) -> Unit, + cars: List, + tracks: List?, + sorts: List, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(SessionFilterBarHeight) + .background(TrackProTheme.colors.panel) + .horizontalScroll(rememberScrollState()) + .padding(horizontal = Spacing.gutter, vertical = Spacing.sm), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + // First, not last: at the end of the row it sat past the screen edge on a phone, + // exactly when a filter was on and it was wanted. + if (filter.narrows) { + ClearFiltersChip(onClick = { onChange(filter.cleared()) }) + } + FilterMenuChip( + label = labelFor(filter.vehicleId, cars, all = stringResource(R.string.filter_all_cars), missing = stringResource(R.string.filter_removed_car)), + active = filter.vehicleId != null, + description = stringResource(R.string.filter_by_car), + options = listOf>(null to stringResource(R.string.filter_all_cars)) + cars.map { it.id to it.label }, + selected = filter.vehicleId, + onSelect = { onChange(filter.copy(vehicleId = it)) } + ) + if (tracks != null) { + FilterMenuChip( + label = labelFor(filter.trackId, tracks, all = stringResource(R.string.filter_all_tracks), missing = stringResource(R.string.filter_removed_track)), + active = filter.trackId != null, + description = stringResource(R.string.filter_by_track), + options = listOf>(null to stringResource(R.string.filter_all_tracks)) + tracks.map { it.id to it.label }, + selected = filter.trackId, + onSelect = { onChange(filter.copy(trackId = it)) } + ) + } + FilterMenuChip( + label = stringResource(filter.period.label), + active = filter.period != SessionPeriod.ALL, + description = stringResource(R.string.filter_by_date), + options = SessionPeriod.entries.map { it to stringResource(it.label) }, + selected = filter.period, + onSelect = { onChange(filter.copy(period = it)) } + ) + // Order hides nothing, so it is never lit - only the filters are. + FilterMenuChip( + label = stringResource(filter.sort.label), + active = false, + description = stringResource(R.string.filter_sort), + options = sorts.map { it to stringResource(it.label) }, + selected = filter.sort, + onSelect = { onChange(filter.copy(sort = it)) } + ) + } +} + +/** The chip's text: the chosen option's label, or a stand-in for "any" or a vanished id. */ +private fun labelFor(id: Long?, options: List, all: String, missing: String): String = + if (id == null) all else options.firstOrNull { it.id == id }?.label ?: missing + +/** + * A chip that opens a menu of choices. Lit in the accent while it is narrowing the list. + */ +@Composable +private fun FilterMenuChip( + label: String, + active: Boolean, + description: String, + options: List>, + selected: T, + onSelect: (T) -> Unit +) { + var open by remember { mutableStateOf(false) } + val ink = if (active) TrackProTheme.colors.onAccent else TrackProTheme.colors.marking + + Box { + Row( + modifier = Modifier + .heightIn(min = 40.dp) + .clip(TrackProShapes.chip) + .background(if (active) TrackProTheme.colors.accent else TrackProTheme.colors.fieldLive) + .pressable(onClick = { open = true }, scale = 0.96f, role = Role.Button) + .semantics { contentDescription = "$description: $label" } + .padding(start = Spacing.md, end = Spacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = label, + style = TrackProType.titleMedium, + color = ink, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(Modifier.width(2.dp)) + Icon( + imageVector = Icons.Default.KeyboardArrowDown, + contentDescription = null, + tint = ink, + modifier = Modifier.size(18.dp) + ) + } + DropdownMenu( + expanded = open, + onDismissRequest = { open = false }, + modifier = Modifier.background(TrackProTheme.colors.fieldLive) + ) { + options.forEach { (value, text) -> + val isSelected = value == selected + DropdownMenuItem( + text = { + Text( + text = text, + style = TrackProType.titleMedium, + color = if (isSelected) TrackProTheme.colors.accent else TrackProTheme.colors.marking + ) + }, + trailingIcon = if (isSelected) { + { + Icon( + Icons.Default.Check, + contentDescription = stringResource(R.string.common_selected), + tint = TrackProTheme.colors.accent, + modifier = Modifier.size(18.dp) + ) + } + } else { + null + }, + onClick = { + open = false + onSelect(value) + } + ) + } + } + } +} + +/** Resets every filter, keeping the order. */ +@Composable +private fun ClearFiltersChip(onClick: () -> Unit) { + Row( + modifier = Modifier + .heightIn(min = 40.dp) + .clip(TrackProShapes.chip) + .pressable(onClick = onClick, scale = 0.96f, role = Role.Button, haptic = Haptic.Selection) + .padding(horizontal = Spacing.sm), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Close, + contentDescription = null, + tint = TrackProTheme.colors.markingDim, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.width(Spacing.xs)) + Text(stringResource(R.string.filter_clear), style = TrackProType.titleMedium, color = TrackProTheme.colors.markingDim) + } +} diff --git a/app/src/main/java/com/example/trackpro/screens/history/SessionFilters.kt b/app/src/main/java/com/example/trackpro/screens/history/SessionFilters.kt new file mode 100644 index 0000000..9d507bf --- /dev/null +++ b/app/src/main/java/com/example/trackpro/screens/history/SessionFilters.kt @@ -0,0 +1,140 @@ +package com.example.trackpro.screens.history + +import com.example.trackpro.R +import androidx.annotation.StringRes +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.listSaver +import java.time.ZonedDateTime + +/** How far back the History list reaches. Calendar days, counting today. */ +enum class SessionPeriod(@StringRes val label: Int) { + ALL(R.string.period_all), + LAST_7_DAYS(R.string.period_7d), + LAST_30_DAYS(R.string.period_30d), + THIS_YEAR(R.string.period_year); + + /** + * The earliest start time this period includes, or null for no limit. + * + * Days are calendar days in the phone's time zone, not rolling 24-hour blocks: "last 7 + * days" run from midnight six days ago, so a session from the morning of that day is + * in however late in today it is asked. + */ + fun startsFrom(now: ZonedDateTime): Long? { + val today = now.toLocalDate() + val from = when (this) { + ALL -> return null + LAST_7_DAYS -> today.minusDays(6) + LAST_30_DAYS -> today.minusDays(29) + THIS_YEAR -> today.withDayOfYear(1) + } + return from.atStartOfDay(now.zone).toInstant().toEpochMilli() + } +} + +/** The order sessions are listed in. */ +enum class SessionSort(@StringRes val label: Int) { + NEWEST(R.string.sort_newest), + OLDEST(R.string.sort_oldest), + /** + * By each session's best lap. Only meaningful within one track - a lap of a short circuit + * is not faster than one of a long circuit in any useful sense - so it orders the sessions + * inside each track, while the tracks themselves stay in order of recent use. + */ + FASTEST(R.string.sort_fastest); +} + +/** + * What the History list is narrowed to, and in what order. Null ids mean "any". + */ +data class SessionFilter( + val vehicleId: Long? = null, + val trackId: Long? = null, + val period: SessionPeriod = SessionPeriod.ALL, + val sort: SessionSort = SessionSort.NEWEST +) { + /** Whether anything is being hidden. The sort order hides nothing, so it does not count. */ + val narrows: Boolean + get() = vehicleId != null || trackId != null || period != SessionPeriod.ALL + + /** Drops every filter but keeps the chosen order. */ + fun cleared(): SessionFilter = SessionFilter(sort = sort) +} + +/** The parts of a session the filters look at, whatever list it came from. */ +data class SessionKey( + val vehicleId: Long?, + val trackId: Long?, + val startTime: Long, + /** Best completed lap in ms, or null for a session without one (and for drag runs). */ + val bestLapMs: Long? = null +) + +/** + * The sessions [filter] lets through, in its order. + * + * Generic over the row type because the two History sections list different things - track + * sessions and drag runs - and should narrow and order them the same way. + */ +fun List.applySessionFilter( + filter: SessionFilter, + now: ZonedDateTime, + keyOf: (T) -> SessionKey +): List { + val from = filter.period.startsFrom(now) + return filter { item -> + val key = keyOf(item) + (filter.vehicleId == null || key.vehicleId == filter.vehicleId) && + (filter.trackId == null || key.trackId == filter.trackId) && + (from == null || key.startTime >= from) + }.sortedWith(sessionOrder(filter.sort, keyOf)) +} + +private fun sessionOrder(sort: SessionSort, keyOf: (T) -> SessionKey): Comparator = + when (sort) { + SessionSort.NEWEST -> compareByDescending { keyOf(it).startTime } + SessionSort.OLDEST -> compareBy { keyOf(it).startTime } + // Sessions that never completed a lap have nothing to rank on; they go last, newest + // first among themselves. + SessionSort.FASTEST -> compareBy(nullsLast()) { keyOf(it).bestLapMs } + .thenByDescending { keyOf(it).startTime } + } + +/** + * Splits already-ordered sessions into groups (a track, a day), keeping each group's members + * in that order. + * + * Groups come in order of their first member, so "newest first" also puts the most recently + * used group first. The exception is [SessionSort.FASTEST]: best laps on different tracks do + * not compare, so there the groups are ordered by their most recent session instead. + */ +fun List.groupSessions( + sort: SessionSort, + keyOf: (T) -> SessionKey, + groupOf: (T) -> G +): List>> { + val groups = groupBy(groupOf).toList() + return if (sort == SessionSort.FASTEST) { + groups.sortedByDescending { (_, members) -> members.maxOf { keyOf(it).startTime } } + } else { + groups + } +} + +private const val NONE = -1L + +/** + * Keeps a filter across rotation and while a session is open on top of the list. Ids are + * never negative, so -1 can stand for "any" in the saved form. + */ +val SessionFilterSaver: Saver = listSaver( + save = { listOf(it.vehicleId ?: NONE, it.trackId ?: NONE, it.period.name, it.sort.name) }, + restore = { saved -> + SessionFilter( + vehicleId = (saved[0] as Long).takeIf { it != NONE }, + trackId = (saved[1] as Long).takeIf { it != NONE }, + period = runCatching { SessionPeriod.valueOf(saved[2] as String) }.getOrDefault(SessionPeriod.ALL), + sort = runCatching { SessionSort.valueOf(saved[3] as String) }.getOrDefault(SessionSort.NEWEST) + ) + } +) diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/DragTimesList.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/DragTimesList.kt index e6004fd..6c0d6a4 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/DragTimesList.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/DragTimesList.kt @@ -1,55 +1,59 @@ package com.example.trackpro.screens.listViewScreens -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background +import androidx.compose.ui.res.stringResource +import com.example.trackpro.R import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.navigation.NavController -import com.example.trackpro.extrasForUI.TrackProTheme -import com.example.trackpro.components.isScrolledUnderChrome -import com.example.trackpro.components.ScreenScaffold -import com.example.trackpro.components.countLabel -import com.example.trackpro.components.pressable import com.example.trackpro.components.ConfirmDeleteDialog import com.example.trackpro.components.DataGate import com.example.trackpro.components.EmptyState -import com.example.trackpro.components.VoidStamp import com.example.trackpro.components.ExpandableGroup -import com.example.trackpro.theme.atSize -import com.example.trackpro.theme.Spacing -import com.example.trackpro.theme.TrackProShapes -import com.example.trackpro.theme.TrackProType +import com.example.trackpro.components.ScreenScaffold +import com.example.trackpro.components.SectionSwitchHeight +import com.example.trackpro.components.SessionRow +import com.example.trackpro.components.countLabel +import com.example.trackpro.components.isScrolledUnderChrome +import com.example.trackpro.extrasForUI.TrackProTheme import com.example.trackpro.managerClasses.utilities.DateFormatterUtil import com.example.trackpro.models.DragSessionWithVehicle +import com.example.trackpro.screens.history.FilterOption +import com.example.trackpro.screens.history.SessionFilter +import com.example.trackpro.screens.history.SessionFilterBar +import com.example.trackpro.screens.history.SessionFilterBarHeight +import com.example.trackpro.screens.history.SessionKey +import com.example.trackpro.screens.history.SessionSort +import com.example.trackpro.screens.history.applySessionFilter +import com.example.trackpro.screens.history.groupSessions +import com.example.trackpro.theme.Spacing +import com.example.trackpro.theme.TrackProType import com.example.trackpro.viewModels.DragSessionViewModel +import java.time.ZonedDateTime import java.util.Date -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +/** + * What drag runs can be ordered by. No "fastest": a run's times are derived from its GPS trace + * when it is opened, so ranking the list would mean replaying every run first. + */ +private val DRAG_SORTS = listOf(SessionSort.NEWEST, SessionSort.OLDEST) + @Composable fun DragTimesListView( viewModel: DragSessionViewModel, @@ -57,75 +61,138 @@ fun DragTimesListView( /** Null when shown as a tab root, where there is nothing to go back to. */ onBack: (() -> Unit)? = { navController.popBackStack() }, header: (@Composable () -> Unit)? = null, - title: String = "Drag Records", + title: String = stringResource(R.string.history_drag_records), + filter: SessionFilter = SessionFilter(), + /** Null hides the filter bar. */ + onFilterChange: ((SessionFilter) -> Unit)? = null, ) { val dragSessions by viewModel.dragSessions.collectAsState() val loadState by viewModel.loadState.collectAsState() - val groupedSessions = remember(dragSessions) { - dragSessions.groupBy { session -> + // A track filter never applies here, and a stale "fastest" from elsewhere falls back. + val effective = filter.copy( + trackId = null, + sort = if (filter.sort in DRAG_SORTS) filter.sort else SessionSort.NEWEST + ) + val keyOf: (DragSessionWithVehicle) -> SessionKey = { SessionKey(it.vehicleId, null, it.startTime) } + val shown = remember(dragSessions, effective) { + dragSessions.applySessionFilter(effective, ZonedDateTime.now(), keyOf) + } + val groups = remember(shown, effective.sort) { + shown.groupSessions(effective.sort, keyOf) { session -> val date = DateFormatterUtil.getDateFormat().format(Date(session.startTime)) "$date | ${session.manufacturer} ${session.model}" } } + val carOptions = remember(dragSessions) { + dragSessions.distinctBy { it.vehicleId } + .map { FilterOption(it.vehicleId, "${it.manufacturer} ${it.model}") } + .sortedBy { it.label } + } + val showFilters = onFilterChange != null && dragSessions.isNotEmpty() + val fullHeader: (@Composable () -> Unit)? = if (header == null && !showFilters) null else { + { + Column { + if (header != null) Box(Modifier.fillMaxWidth().height(SectionSwitchHeight)) { header() } + if (showFilters) { + SessionFilterBar( + filter = effective, + onChange = onFilterChange!!, + cars = carOptions, + tracks = null, + sorts = DRAG_SORTS + ) + } + } + } + } + val headerHeight = (if (header != null) SectionSwitchHeight else 0.dp) + + (if (showFilters) SessionFilterBarHeight else 0.dp) val listState = rememberLazyListState() val scrolled by listState.isScrolledUnderChrome() ScreenScaffold( - title = title, - onBack = onBack, - header = header, - accent = TrackProTheme.colors.accent, - trailing = { - Text( - countLabel(dragSessions.size, "run"), - style = TrackProType.label, - color = TrackProTheme.colors.textMuted - ) - }, + title = title, + onBack = onBack, + header = fullHeader, + headerHeight = headerHeight, + accent = TrackProTheme.colors.accent, + trailing = { + Text( + text = if (effective.narrows) { + stringResource(R.string.history_shown_of, shown.size, countLabel(dragSessions.size, R.plurals.count_runs)) + } else { + countLabel(dragSessions.size, R.plurals.count_runs) + }, + style = TrackProType.label, + color = TrackProTheme.colors.textMuted + ) + }, contentScrolled = scrolled ) { contentPadding -> + // Gated on every run, not the filtered ones: "no runs yet" and "none match these + // filters" are different situations with different ways out. DataGate( state = loadState, items = dragSessions, - emptyMessage = "No sessions recorded", - emptyHint = "Run a drag session to see it here", - loadingLabel = "Reading sessions" + emptyMessage = stringResource(R.string.history_no_drag_runs), + emptyHint = stringResource(R.string.history_no_drag_runs_hint), + loadingLabel = stringResource(R.string.history_reading_runs), + emptyActionLabel = stringResource(R.string.history_start_drag), + onEmptyAction = { navController.navigate("drag") } ) { _ -> + if (shown.isEmpty()) { + EmptyState( + message = stringResource(R.string.history_no_runs_match), + hint = stringResource(R.string.history_no_match_hint), + actionLabel = stringResource(R.string.history_clear_filters), + onAction = { onFilterChange?.invoke(effective.cleared()) }, + modifier = Modifier.padding(top = contentPadding.calculateTopPadding()) + ) + } else { LazyColumn( modifier = Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues( top = contentPadding.calculateTopPadding() + Spacing.md, bottom = Spacing.md, - start = Spacing.md, - end = Spacing.md + start = Spacing.gutter, + end = Spacing.gutter ), verticalArrangement = Arrangement.spacedBy(Spacing.sm) ) { - groupedSessions.forEach { (groupKey, sessions) -> - item(key = groupKey) { + // A lone group opens by itself; see TimeAttackListViewScreen. + val alone = groups.size == 1 + groups.forEach { (groupKey, sessions) -> + item(key = "$groupKey|$alone") { ExpandableSessionGroup( groupTitle = groupKey, sessions = sessions, + initiallyExpanded = alone, navController = navController, onDelete = { viewModel.deleteSession(it) } ) } } } - + } } } } + +/** + * One day's runs in one car. [sessions] arrive already in the chosen order and are shown as + * given - re-sorting here would quietly undo the filter bar's sort. + */ @Composable fun ExpandableSessionGroup( groupTitle: String, sessions: List, navController: NavController, - onDelete: (DragSessionWithVehicle) -> Unit + onDelete: (DragSessionWithVehicle) -> Unit, + initiallyExpanded: Boolean = false, ) { // One slot per group rather than per row: only one dialog can be open at a time, and // holding the pending session here keeps each row from carrying its own state. @@ -134,9 +201,8 @@ fun ExpandableSessionGroup( pendingDelete?.let { session -> val time = DateFormatterUtil.getTimeFormat().format(Date(session.startTime)) ConfirmDeleteDialog( - title = "Delete session?", - message = "The run at $time and all of its recorded data will be " + - "permanently removed.", + title = stringResource(R.string.history_delete_session), + message = stringResource(R.string.history_delete_run_msg, time), onConfirm = { onDelete(session); pendingDelete = null }, onDismiss = { pendingDelete = null } ) @@ -144,56 +210,29 @@ fun ExpandableSessionGroup( ExpandableGroup( accent = TrackProTheme.colors.accent, + initiallyExpanded = initiallyExpanded, header = { Column(modifier = Modifier.weight(1f)) { - Text(groupTitle, style = TrackProType.titleMedium.atSize(13.sp), color = TrackProTheme.colors.textPrimary) + Text(groupTitle, style = TrackProType.titleMedium, color = TrackProTheme.colors.textPrimary) Text( - "${sessions.size} runs completed", - style = TrackProType.body.atSize(11.sp), + countLabel(sessions.size, R.plurals.count_runs), + style = TrackProType.label, color = TrackProTheme.colors.textMuted ) } } ) { - Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Column { sessions.forEach { session -> - val time = DateFormatterUtil.getTimeFormat().format(Date(session.startTime)) - - Row( - modifier = Modifier - .fillMaxWidth() - .pressable( - onClick = { navController.navigate("graph/${session.sessionId}") }, - onLongClick = { pendingDelete = session } - ) - .background(TrackProTheme.colors.bgElevated, TrackProShapes.control) - .padding(Spacing.sm), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( - Modifier.size(6.dp).background(TrackProTheme.colors.accentMuted, RoundedCornerShape(100)) - ) - Spacer(Modifier.width(Spacing.sm)) - Text( - "Run at $time", - style = TrackProType.body, - color = TrackProTheme.colors.textPrimary - ) - } - - if (session.voided) { - VoidStamp() - } else { - Text( - "Details", - style = TrackProType.label, - color = TrackProTheme.colors.textMuted - ) - } - } + SessionRow( + startTime = session.startTime, + title = stringResource(R.string.history_run_at, DateFormatterUtil.getTimeFormat().format(Date(session.startTime))), + subtitle = "${session.manufacturer} ${session.model}", + voided = session.voided, + onClick = { navController.navigate("graph/${session.sessionId}") }, + onLongClick = { pendingDelete = session } + ) } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/TimeAttackListView.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/TimeAttackListView.kt index 7daccb4..74c0e7f 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/TimeAttackListView.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/TimeAttackListView.kt @@ -1,46 +1,52 @@ package com.example.trackpro.screens.listViewScreens -import androidx.compose.foundation.background -import androidx.compose.foundation.border +import com.example.trackpro.managerClasses.utilities.DateFormatterUtil +import androidx.compose.ui.res.stringResource +import com.example.trackpro.R import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import com.example.trackpro.components.localizedCountry +import com.example.trackpro.components.trackTypeLabel import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.navigation.NavController -import com.example.trackpro.dataClasses.SessionData -import com.example.trackpro.dataClasses.VehicleInformationData -import com.example.trackpro.extrasForUI.TrackProTheme -import com.example.trackpro.components.isScrolledUnderChrome -import com.example.trackpro.components.ScreenScaffold -import com.example.trackpro.components.countLabel -import com.example.trackpro.components.pressable import com.example.trackpro.components.ConfirmDeleteDialog import com.example.trackpro.components.DataGate import com.example.trackpro.components.EmptyState -import com.example.trackpro.components.VoidStamp import com.example.trackpro.components.ExpandableGroup -import com.example.trackpro.theme.atSize +import com.example.trackpro.components.ScreenScaffold +import com.example.trackpro.components.SectionSwitchHeight +import com.example.trackpro.components.SessionRow +import com.example.trackpro.components.countLabel +import com.example.trackpro.components.isScrolledUnderChrome +import com.example.trackpro.dataClasses.SessionData +import com.example.trackpro.dataClasses.TrackMainData +import com.example.trackpro.dataClasses.VehicleInformationData +import com.example.trackpro.extrasForUI.TrackProTheme +import com.example.trackpro.managerClasses.utilities.toLapTimeString +import com.example.trackpro.screens.history.FilterOption +import com.example.trackpro.screens.history.SessionFilter +import com.example.trackpro.screens.history.SessionFilterBar +import com.example.trackpro.screens.history.SessionFilterBarHeight +import com.example.trackpro.screens.history.SessionKey +import com.example.trackpro.screens.history.SessionSort +import com.example.trackpro.screens.history.applySessionFilter +import com.example.trackpro.screens.history.groupSessions import com.example.trackpro.theme.Spacing import com.example.trackpro.theme.TrackProType import com.example.trackpro.viewModels.SessionViewModel @@ -48,6 +54,7 @@ import com.example.trackpro.viewModels.TrackViewModel import com.example.trackpro.viewModels.VehicleFULLViewModel import java.time.Instant import java.time.ZoneId +import java.time.ZonedDateTime import java.time.format.DateTimeFormatter @Composable @@ -59,71 +66,150 @@ fun TimeAttackListViewScreen( /** Null when shown as a tab root, where there is nothing to go back to. */ onBack: (() -> Unit)? = { navController.popBackStack() }, header: (@Composable () -> Unit)? = null, - title: String = "Track Records", + title: String = stringResource(R.string.history_track_records), + filter: SessionFilter = SessionFilter(), + /** Null hides the filter bar. */ + onFilterChange: ((SessionFilter) -> Unit)? = null, ) { val allSessions by viewModel.sessions.collectAsState() val loadState by viewModel.loadState.collectAsState() - val trackSessions = allSessions.filter { it.trackId != null } + val bestLaps by viewModel.bestLapMs.collectAsState() + val trackSessions = remember(allSessions) { allSessions.filter { it.trackId != null } } val vehicles by vehicleViewModel.vehicles.collectAsState() val tracks by trackViewModel.tracks.collectAsState() - val groupedByTrack = remember(trackSessions, tracks) { - trackSessions.groupBy { session -> - tracks.find { it.trackId == session.trackId }?.trackName ?: "Unknown Track" - } + val keyOf: (SessionData) -> SessionKey = { session -> + SessionKey(session.vehicleId, session.trackId, session.startTime, bestLaps[session.id]) + } + val shown = remember(trackSessions, filter, bestLaps) { + trackSessions.applySessionFilter(filter, ZonedDateTime.now(), keyOf) + } + val groups = remember(shown, filter.sort, bestLaps) { + shown.groupSessions(filter.sort, keyOf) { it.trackId } } + // Only the cars and tracks these sessions actually use, so every choice shows something. + val removedCar = stringResource(R.string.filter_removed_car) + val removedTrack = stringResource(R.string.filter_removed_track) + val carOptions = remember(trackSessions, vehicles, removedCar) { + trackSessions.mapNotNull { it.vehicleId }.distinct().map { id -> + FilterOption(id, vehicles.find { it.vehicleId == id }?.displayName() ?: removedCar) + }.sortedBy { it.label } + } + val trackOptions = remember(trackSessions, tracks, removedTrack) { + trackSessions.mapNotNull { it.trackId }.distinct().map { id -> + FilterOption(id, tracks.find { it.trackId == id }?.trackName ?: removedTrack) + }.sortedBy { it.label } + } + + // Nothing to narrow in an empty history, so the bar only appears once there is something. + val showFilters = onFilterChange != null && trackSessions.isNotEmpty() + val fullHeader: (@Composable () -> Unit)? = if (header == null && !showFilters) null else { + { + Column { + if (header != null) Box(Modifier.fillMaxWidth().height(SectionSwitchHeight)) { header() } + if (showFilters) { + SessionFilterBar( + filter = filter, + onChange = onFilterChange!!, + cars = carOptions, + tracks = trackOptions, + sorts = SessionSort.entries + ) + } + } + } + } + val headerHeight = (if (header != null) SectionSwitchHeight else 0.dp) + + (if (showFilters) SessionFilterBarHeight else 0.dp) val listState = rememberLazyListState() val scrolled by listState.isScrolledUnderChrome() ScreenScaffold( - title = title, - onBack = onBack, - header = header, - accent = TrackProTheme.colors.accent, - trailing = { - Text(countLabel(trackSessions.size, "session"), style = TrackProType.label, color = TrackProTheme.colors.textMuted) - }, + title = title, + onBack = onBack, + header = fullHeader, + headerHeight = headerHeight, + accent = TrackProTheme.colors.accent, + trailing = { + Text( + text = if (filter.narrows) { + stringResource(R.string.history_shown_of, shown.size, countLabel(trackSessions.size, R.plurals.count_sessions)) + } else { + countLabel(trackSessions.size, R.plurals.count_sessions) + }, + style = TrackProType.label, + color = TrackProTheme.colors.textMuted + ) + }, contentScrolled = scrolled ) { contentPadding -> + // Gated on every track session, not the filtered ones: "no sessions yet" and "none + // match these filters" are different situations with different ways out. DataGate( state = loadState, items = trackSessions, - emptyMessage = "No sessions recorded", - emptyHint = "Run a track session to see it here", - loadingLabel = "Reading sessions" + emptyMessage = stringResource(R.string.history_no_track_sessions), + emptyHint = stringResource(R.string.history_no_track_sessions_hint), + loadingLabel = stringResource(R.string.history_reading_sessions), + emptyActionLabel = stringResource(R.string.history_start_track), + onEmptyAction = { navController.navigate("trackandvehicle") } ) { _ -> + if (shown.isEmpty()) { + EmptyState( + message = stringResource(R.string.history_no_sessions_match), + hint = stringResource(R.string.history_no_match_hint), + actionLabel = stringResource(R.string.history_clear_filters), + onAction = { onFilterChange?.invoke(filter.cleared()) }, + modifier = Modifier.padding(top = contentPadding.calculateTopPadding()) + ) + } else { LazyColumn( modifier = Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues( top = contentPadding.calculateTopPadding() + Spacing.md, bottom = Spacing.md, - start = Spacing.md, - end = Spacing.md + start = Spacing.gutter, + end = Spacing.gutter ), verticalArrangement = Arrangement.spacedBy(Spacing.sm) ) { - groupedByTrack.forEach { (trackName, sessions) -> - item(key = trackName) { - val track = tracks.find { it.trackName == trackName } + // A single group - one track picked, or only one driven - opens by itself; + // collapsed, the filter would seem to have produced an empty card. The key + // carries that, so the group resets when it becomes or stops being alone. + val alone = groups.size == 1 + groups.forEach { (trackId, sessions) -> + item(key = "$trackId|$alone") { + val track = tracks.find { it.trackId == trackId } ExpandableTrackGroup( - trackName = trackName, - trackMeta = "${track?.country} · ${track?.type}", + trackName = track?.trackName ?: stringResource(R.string.history_unknown_track), + trackMeta = track?.meta() ?: "", sessions = sessions, vehicles = vehicles, + bestLaps = bestLaps, + initiallyExpanded = alone, navController = navController, onDelete = { viewModel.deleteSession(it) } ) } } } - + } } } } +private fun VehicleInformationData.displayName() = "$manufacturer $model" + +@Composable +private fun TrackMainData.meta() = "${localizedCountry(country)} · ${trackTypeLabel(type)}" + +/** + * One track's sessions. [sessions] arrive already in the chosen order and are shown as given - + * re-sorting here would quietly undo the filter bar's sort. + */ @Composable fun ExpandableTrackGroup( trackName: String, @@ -131,7 +217,9 @@ fun ExpandableTrackGroup( sessions: List, vehicles: List, navController: NavController, - onDelete: (SessionData) -> Unit + onDelete: (SessionData) -> Unit, + bestLaps: Map = emptyMap(), + initiallyExpanded: Boolean = false, ) { // One slot per group rather than per row: only one dialog can be open at a time, and // holding the pending session here keeps each row from carrying its own state. @@ -139,11 +227,10 @@ fun ExpandableTrackGroup( pendingDelete?.let { session -> val date = Instant.ofEpochMilli(session.startTime) - .atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("dd MMM")) + .atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern(DateFormatterUtil.dayMonthPattern())) ConfirmDeleteDialog( - title = "Delete session?", - message = "The $trackName session from $date, including every lap and " + - "sector time, will be permanently removed.", + title = stringResource(R.string.history_delete_session), + message = stringResource(R.string.history_delete_track_session_msg, trackName, date), onConfirm = { onDelete(session); pendingDelete = null }, onDismiss = { pendingDelete = null } ) @@ -151,59 +238,36 @@ fun ExpandableTrackGroup( ExpandableGroup( accent = TrackProTheme.colors.accent, + initiallyExpanded = initiallyExpanded, header = { Column(modifier = Modifier.weight(1f)) { - Text(trackName, style = TrackProType.titleMedium.atSize(14.sp), color = TrackProTheme.colors.textPrimary) + Text(trackName, style = TrackProType.titleMedium, color = TrackProTheme.colors.textPrimary) Text( - "$trackMeta · ${sessions.size} sessions", - style = TrackProType.body.atSize(11.sp), + listOf(trackMeta, countLabel(sessions.size, R.plurals.count_sessions)) + .filter { it.isNotBlank() } + .joinToString(" · "), + style = TrackProType.label, color = TrackProTheme.colors.textMuted ) } } ) { - Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Column { sessions.forEach { session -> val vehicle = vehicles.find { it.vehicleId == session.vehicleId } - val date = Instant.ofEpochMilli(session.startTime) - .atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("dd MMM")) - - Row( - modifier = Modifier - .fillMaxWidth() - .pressable( - onClick = { navController.navigate("timeattacklistitem/${session.id}") }, - onLongClick = { pendingDelete = session } - ) - .background(TrackProTheme.colors.bgElevated.copy(alpha = 0.4f), RoundedCornerShape(0.dp)) - .padding(Spacing.sm), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - // Vertical "Pillar" accent - Box(modifier = Modifier.width(2.dp).height(24.dp).background(TrackProTheme.colors.accentMuted, RoundedCornerShape(2.dp))) - Spacer(Modifier.width(Spacing.sm)) - Column { - Text( - "${vehicle?.manufacturer} ${vehicle?.model}", - style = TrackProType.body, - color = TrackProTheme.colors.textPrimary - ) - Text( - "Session date: $date", - style = TrackProType.body.atSize(10.sp), - color = TrackProTheme.colors.textMuted - ) - } - } - - if (session.voided) { - VoidStamp() - } else { - Text("Telemetry", style = TrackProType.label, color = TrackProTheme.colors.textMuted) - } - } + val startedAt = Instant.ofEpochMilli(session.startTime).atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofPattern(DateFormatterUtil.weekdayTimePattern())) + SessionRow( + startTime = session.startTime, + title = vehicle?.displayName() ?: stringResource(R.string.history_unknown_car), + // The best lap is on the row so ordering by pace can be seen, not trusted. + subtitle = bestLaps[session.id] + ?.let { stringResource(R.string.history_session_best, startedAt, it.toLapTimeString()) } + ?: startedAt, + voided = session.voided, + onClick = { navController.navigate("timeattacklistitem/${session.id}") }, + onLongClick = { pendingDelete = session } + ) } } } diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/TrackListView.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/TrackListView.kt index 41c5684..29b0a4e 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/TrackListView.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/TrackListView.kt @@ -1,5 +1,7 @@ package com.example.trackpro.screens.listViewScreens +import androidx.compose.ui.res.stringResource +import com.example.trackpro.R import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -15,6 +17,17 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import com.example.trackpro.components.localizedCountry +import com.example.trackpro.components.trackTypeLabel +import com.example.trackpro.theme.TrackProShapes +import com.example.trackpro.theme.Spacing +import com.example.trackpro.components.PaddockCard +import com.example.trackpro.components.IconCircle +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.filled.Flag +import androidx.compose.material.icons.filled.AddLocationAlt +import androidx.compose.material.icons.Icons import com.example.trackpro.components.DashAction import androidx.compose.foundation.layout.Box import androidx.compose.runtime.LaunchedEffect @@ -65,7 +78,7 @@ fun TrackListScreen( header: (@Composable () -> Unit)? = null, /** When set, the list opens with the way to build a new track. */ onBuildTrack: (() -> Unit)? = null, - title: String = "My Tracks", + title: String = stringResource(R.string.tracks_my), ) { val tracks by viewModel.tracks.collectAsState() val loadState by viewModel.loadState.collectAsState() @@ -85,7 +98,7 @@ fun TrackListScreen( accent = TrackProTheme.colors.accent, trailing = { Text( - text = countLabel(tracks.size, "track"), + text = countLabel(tracks.size, R.plurals.count_tracks), style = TrackProType.label, color = TrackProTheme.colors.textMuted ) @@ -96,35 +109,40 @@ fun TrackListScreen( if (onBuildTrack != null) { // The builder lives with the tracks it makes. It used to be an instrument on the // home panel, one tap from the modes you drive with. - Box(modifier = Modifier.padding(top = contentPadding.calculateTopPadding()).padding(12.dp)) { + Box( + modifier = Modifier + .padding(top = contentPadding.calculateTopPadding() + Spacing.xs) + .padding(horizontal = Spacing.gutter) + ) { DashAction( - label = "Build a track", - detail = "Walk or drive it · or draw it on the map", + label = stringResource(R.string.tracks_build), + detail = stringResource(R.string.tracks_build_hint), onClick = onBuildTrack, - compact = true + icon = Icons.Default.AddLocationAlt ) } - Bezel() } val listTop = if (onBuildTrack != null) 0.dp else contentPadding.calculateTopPadding() DataGate( state = loadState, items = tracks, - emptyMessage = "No tracks yet", - emptyHint = "Build a track to see it here", - loadingLabel = "Reading tracks" + emptyMessage = stringResource(R.string.tracks_none), + emptyHint = stringResource(R.string.tracks_none_hint), + loadingLabel = stringResource(R.string.tracks_reading) ) { _ -> LazyColumn( modifier = Modifier.fillMaxSize(), state = listState, contentPadding = PaddingValues( - top = listTop + 10.dp, - bottom = 24.dp + start = Spacing.gutter, + end = Spacing.gutter, + top = listTop + Spacing.md, + bottom = Spacing.xl ), // Each track is its own aperture in the panel; the gap between them is the // panel showing through, which is what gives the list air without inventing // a card. - verticalArrangement = Arrangement.spacedBy(10.dp) + verticalArrangement = Arrangement.spacedBy(Spacing.md) ) { items(tracks) { track -> TrackCard( @@ -181,63 +199,55 @@ fun TrackCard( if (showDeleteDialog) { ConfirmDeleteDialog( - title = "Delete track?", - message = "${track.trackName} and every lap recorded on it will be " + - "permanently removed.", + title = stringResource(R.string.tracks_delete_title), + message = stringResource(R.string.tracks_delete_msg, track.trackName), onConfirm = { onDelete(track); showDeleteDialog = false }, onDismiss = { showDeleteDialog = false } ) } - Column( - modifier = Modifier - .fillMaxWidth() - .pressableRow(onClick = { navController.navigate("track/${track.trackId}") }) - .background(TrackProTheme.colors.field) + PaddockCard( + onClick = { navController.navigate("track/${track.trackId}") }, + onLongClick = { showDeleteDialog = true }, + padding = Spacing.lg ) { - // The name leads by a clear margin - 26sp against 17sp readouts. The previous - // pass set it at 19sp beside 18sp values, so nothing led and every row read as - // one undifferentiated block. - Column( - modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 18.dp, bottom = 14.dp) - ) { - Text( - text = track.trackName, - style = TrackProType.titleLarge.atSize(26.sp), - color = TrackProTheme.colors.marking, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(Modifier.height(6.dp)) - Text( - text = listOf(track.type, track.country) - .filter { it.isNotBlank() } - .joinToString(" · ") - .uppercase(), - style = TrackProType.label, - color = TrackProTheme.colors.markingDim - ) + Row(verticalAlignment = Alignment.CenterVertically) { + IconCircle(icon = Icons.Default.Flag, size = 44.dp) + Spacer(Modifier.width(Spacing.md)) + Column(Modifier.weight(1f)) { + Text( + text = track.trackName, + style = TrackProType.titleMedium, + color = TrackProTheme.colors.marking, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = listOf(trackTypeLabel(track.type), localizedCountry(track.country)).filter { it.isNotBlank() }.joinToString(" · "), + style = TrackProType.label, + color = TrackProTheme.colors.markingDim + ) + } } - - Bezel() - - Row(modifier = Modifier.fillMaxWidth()) { + Spacer(Modifier.height(Spacing.md)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(TrackProShapes.control) + .background(TrackProTheme.colors.bgElevated) + ) { Instrument( - label = "Length", + label = stringResource(R.string.tracks_length), // totalLength is stored in km; formatDistance takes meters. - value = track.totalLength?.let { - UnitFormatter.formatDistance(it * 1000.0, useMetric) - } ?: "—", - valueSize = 17.sp, + value = track.totalLength?.let { UnitFormatter.formatDistance(it * 1000.0, useMetric) } ?: "—", + valueSize = 15.sp, modifier = Modifier.weight(1f) ) - Bezel(vertical = true, modifier = Modifier.height(56.dp)) Instrument( - label = "Lap record", + label = stringResource(R.string.tracks_your_record), value = bestLapTime ?: "—", - valueColor = if (bestLapTime != null) TrackProTheme.colors.accent - else TrackProTheme.colors.markingDim, - valueSize = 17.sp, + valueColor = if (bestLapTime != null) TrackProTheme.colors.accent else TrackProTheme.colors.markingDim, + valueSize = 15.sp, modifier = Modifier.weight(1f) ) } diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/CarViewScreen.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/CarViewScreen.kt index 8e12d17..77dfe41 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/CarViewScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/CarViewScreen.kt @@ -1,5 +1,7 @@ package com.example.trackpro.screens.listViewScreens.listItems +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -18,6 +20,11 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import com.example.trackpro.components.specLabel +import com.example.trackpro.theme.marking +import com.example.trackpro.components.SectionTitle +import com.example.trackpro.components.PaddockCard +import com.example.trackpro.components.Bezel import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -81,7 +88,7 @@ fun CarViewScreen( // The bar now wraps both states - previously it only existed in the loaded branch, // so the screen had no header (and no back affordance) while loading. ScreenScaffold( - title = "Vehicle Profile", + title = stringResource(R.string.car_title), onBack = onBack, accent = TrackProTheme.colors.accent, contentScrolled = scrolled @@ -95,7 +102,7 @@ fun CarViewScreen( strokeWidth = 2.dp ) Spacer(Modifier.height(12.dp)) - Text("Loading vehicle", style = TrackProType.label, color = TrackProTheme.colors.markingDim) + Text(stringResource(R.string.car_loading), style = TrackProType.label, color = TrackProTheme.colors.markingDim) } } } else { @@ -103,169 +110,97 @@ fun CarViewScreen( LazyColumn( modifier = Modifier.fillMaxSize(), state = listState, - contentPadding = PaddingValues(top = contentPadding.calculateTopPadding()) + contentPadding = PaddingValues(top = contentPadding.calculateTopPadding(), bottom = Spacing.xl) ) { - - // ── Photo ───────────────────────────── - item(key = "photo") { CarPhotoSection(vehicle) } - - // ── Hero ────────────────────────────── - item { - Column( - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.bgCard) - .padding(horizontal = Spacing.lg, vertical = Spacing.md) - ) { - Text( - text = "${vehicle.manufacturer} ${vehicle.model}".uppercase(), - // The car leads its own spec sheet. - style = TrackProType.titleLarge.atSize(26.sp), - color = TrackProTheme.colors.textPrimary - ) - Spacer(Modifier.height(6.dp)) - Text( - text = listOfNotNull( - vehicle.year.takeIf { it > 0 }?.toString(), - vehicle.drivetrain.takeIf { it.isNotBlank() } - ).joinToString(" · ").uppercase(), - style = TrackProType.label, - color = TrackProTheme.colors.textMuted - ) - } - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) + item(key = "photo") { CarPhotoSection(vehicle) } + + item(key = "hero") { + Column(Modifier.padding(horizontal = Spacing.gutter, vertical = Spacing.lg)) { + Text( + text = "${vehicle.manufacturer} ${vehicle.model}", + style = TrackProType.titleLarge, + color = TrackProTheme.colors.marking + ) + Text( + text = listOfNotNull( + vehicle.year.takeIf { it > 0 }?.toString(), + vehicle.engineType.takeIf { it.isNotBlank() }?.let { specLabel(it) }, + vehicle.drivetrain.takeIf { it.isNotBlank() } + ).joinToString(" · "), + style = TrackProType.label, + color = TrackProTheme.colors.markingDim + ) + Spacer(Modifier.height(Spacing.md)) + CarBackupStrip(vehicle, onSignIn = onSignIn) } + } - // ── Account backup ──────────────────── - item(key = "backup") { CarBackupStrip(vehicle, onSignIn = onSignIn) } - - // ── Its laps ────────────────────────── - item(key = "bests") { CarBests(vehicle.vehicleId, onOpenTrack = onOpenTrack) } - - // ── Performance stats ───────────────── - item { - SectionLabel("Performance", modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm)) - Row( - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.bgCard) - .padding(horizontal = Spacing.lg, vertical = Spacing.md), - horizontalArrangement = Arrangement.SpaceBetween - ) { - StatCell(label = "Power", value = "${vehicle.horsepower}", unit = "hp", size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally) - StatCell(label = "Torque", value = vehicle.torque?.toString() ?: "—", unit = "Nm", size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally) - StatCell(label = "Weight", value = "${vehicle.weight}", unit = "kg", size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally) + item(key = "bests") { CarBests(vehicle.vehicleId, onOpenTrack = onOpenTrack) } + + item(key = "performance") { + SectionTitle(stringResource(R.string.car_performance)) + Column(Modifier.padding(horizontal = Spacing.gutter)) { + PaddockCard { + Row(Modifier.fillMaxWidth()) { + StatCell(label = stringResource(R.string.car_power), value = "${vehicle.horsepower}", unit = "hp", modifier = Modifier.weight(1f)) + StatCell(label = stringResource(R.string.car_torque), value = vehicle.torque?.toString() ?: "—", unit = "Nm", modifier = Modifier.weight(1f)) + StatCell(label = stringResource(R.string.car_weight), value = "${vehicle.weight.toInt()}", unit = "kg", modifier = Modifier.weight(1f)) + } + Spacer(Modifier.height(Spacing.lg)) + Row(Modifier.fillMaxWidth()) { + StatCell( + label = stringResource(R.string.car_top_speed), + value = vehicle.topSpeed?.let { UnitFormatter.formatSpeed(it, useMetric) } ?: "—", + unit = UnitFormatter.speedUnitLabel(useMetric).lowercase(), + modifier = Modifier.weight(1f) + ) + StatCell(label = "0–100", value = vehicle.acceleration?.toString() ?: "—", unit = "s", modifier = Modifier.weight(1f)) + StatCell(label = stringResource(R.string.car_drivetrain), value = vehicle.drivetrain.ifBlank { "—" }, modifier = Modifier.weight(1f)) + } } - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) } + } - // ── Speed stats ─────────────────────── - item { - Row( - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.bgElevated) - .padding(horizontal = Spacing.lg, vertical = Spacing.md), - horizontalArrangement = Arrangement.SpaceBetween - ) { - StatCell( - label = "Top Speed", - value = vehicle.topSpeed?.let { UnitFormatter.formatSpeed(it, useMetric) } ?: "—", - unit = UnitFormatter.speedUnitLabel(useMetric).lowercase(), - size = StatCellSize.Large, - horizontalAlignment = Alignment.CenterHorizontally - ) - StatCell( - label = "0–100", - value = vehicle.acceleration?.toString() ?: "—", - unit = "sec", - size = StatCellSize.Large, - horizontalAlignment = Alignment.CenterHorizontally + item(key = "specs") { + SectionTitle(stringResource(R.string.car_specs)) + Column(Modifier.padding(horizontal = Spacing.gutter)) { + PaddockCard(padding = 0.dp) { + val specs = listOfNotNull( + stringResource(R.string.car_engine) to specLabel(vehicle.engineType), + stringResource(R.string.car_transmission) to specLabel(vehicle.transmission), + stringResource(R.string.car_fuel) to specLabel(vehicle.fuelType), + vehicle.fuelCapacity?.let { stringResource(R.string.car_fuel_capacity) to "$it L" }, + vehicle.suspensionType?.let { stringResource(R.string.car_suspension) to specLabel(it) }, + stringResource(R.string.car_tyres) to specLabel(vehicle.tireType), ) - StatCell( - label = "Drivetrain", - value = vehicle.drivetrain, - size = StatCellSize.Large, - horizontalAlignment = Alignment.CenterHorizontally - ) - } - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) - } - - // ── Mechanical details ──────────────── - item { - SectionLabel("Mechanical", modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm)) - } - item { - VehicleInfoRow("Engine Type", vehicle.engineType, - TrackProTheme.colors.textPrimary, TrackProTheme.colors.textMuted, TrackProTheme.colors.sectorLine, TrackProTheme.colors.bgCard) - } - item { - VehicleInfoRow("Transmission", vehicle.transmission, - TrackProTheme.colors.textPrimary, TrackProTheme.colors.textMuted, TrackProTheme.colors.sectorLine, TrackProTheme.colors.bgCard) - } - item { - VehicleInfoRow("Fuel Type", vehicle.fuelType, - TrackProTheme.colors.textPrimary, TrackProTheme.colors.textMuted, TrackProTheme.colors.sectorLine, TrackProTheme.colors.bgCard) - } - vehicle.fuelCapacity?.let { - item { - VehicleInfoRow("Fuel Capacity", "$it L", - TrackProTheme.colors.textPrimary, TrackProTheme.colors.textMuted, TrackProTheme.colors.sectorLine, TrackProTheme.colors.bgCard) - } - } - vehicle.suspensionType?.let { - item { - VehicleInfoRow("Suspension", it, - TrackProTheme.colors.textPrimary, TrackProTheme.colors.textMuted, TrackProTheme.colors.sectorLine, TrackProTheme.colors.bgCard) + specs.forEachIndexed { i, (label, value) -> + if (i > 0) Bezel(Modifier.padding(horizontal = Spacing.lg)) + VehicleInfoRow(label, value) + } } } - - // ── Tyres ───────────────────────────── - item { - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) - SectionLabel("Tyres & Setup", modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm)) - } - item { - VehicleInfoRow("Tyre Type", vehicle.tireType, - TrackProTheme.colors.textPrimary, TrackProTheme.colors.textMuted, TrackProTheme.colors.sectorLine, TrackProTheme.colors.bgCard) - } - - item(key = "delete") { CarDeleteRow(vehicle, onDeleted = onBack) } - - item { Spacer(Modifier.height(Spacing.xl)) } } + + item(key = "delete") { CarDeleteRow(vehicle, onDeleted = onBack) } + } } } } @Composable -private fun VehicleInfoRow( - label: String, - value: String, - textPrimary: Color, - textMuted: Color, - sectorLine: Color, - bgCard: Color -) { - Column( +private fun VehicleInfoRow(label: String, value: String) { + Row( modifier = Modifier .fillMaxWidth() - .background(bgCard) + .padding(horizontal = Spacing.lg, vertical = Spacing.md), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Spacing.lg, vertical = Spacing.sm), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text(label.uppercase(), style = TrackProType.label, color = textMuted) - Text(value, style = TrackProType.body, color = textPrimary) - } - HorizontalDivider(color = sectorLine, thickness = 1.dp) + Text(label, style = TrackProType.body, color = TrackProTheme.colors.markingDim) + Text(value.ifBlank { "—" }, style = TrackProType.titleMedium, color = TrackProTheme.colors.marking) } } + @Preview( showBackground = true, ) diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/DragScreenListItem.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/DragScreenListItem.kt index d99aa6d..e03c593 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/DragScreenListItem.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/DragScreenListItem.kt @@ -1,5 +1,7 @@ package com.example.trackpro.screens.listViewScreens.listItems +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import android.annotation.SuppressLint import android.view.ViewGroup import androidx.compose.foundation.background @@ -14,6 +16,14 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Divider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.foundation.layout.Spacer +import com.example.trackpro.theme.TrackProShapes +import com.example.trackpro.components.paddockCard +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.heightIn import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -247,15 +257,16 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { .fillMaxSize() .background(TrackProTheme.colors.bgDeep) ) { - AppTopBar(title = "Session Overview", accent = TrackProTheme.colors.accent, onBack = onBack) + AppTopBar(title = stringResource(R.string.drag_run_title), accent = TrackProTheme.colors.accent, onBack = onBack) // ── Compact stats panel ─────────────────────────────── Column( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.bgCard) - .padding(horizontal = Spacing.md, vertical = Spacing.sm), - verticalArrangement = Arrangement.spacedBy(6.dp) + .padding(horizontal = Spacing.gutter) + .paddockCard() + .padding(Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.sm) ) { Row( modifier = Modifier.fillMaxWidth(), @@ -264,12 +275,13 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { ) { if (coordinates.isNotEmpty()) { val totalTime = coordinates.last().timestamp - coordinates.first().timestamp - StatCell(label = "Duration", value = formatTime(totalTime), size = StatCellSize.Small) + StatCell(label = stringResource(R.string.drag_duration), value = formatTime(totalTime), size = StatCellSize.Regular) } StatCell( - label = "Distance", + label = stringResource(R.string.drag_distance), value = if (totalDist <= 0) "—" else UnitFormatter.formatDistance(totalDist, useMetric), - size = StatCellSize.Small + size = StatCellSize.Regular, + horizontalAlignment = Alignment.End ) } @@ -284,22 +296,22 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { verticalAlignment = Alignment.CenterVertically ) { StatCell( - label = "Air Temp", + label = stringResource(R.string.drag_air_temp), value = UnitFormatter.formatTemperature(tempC, useMetric), size = StatCellSize.Small ) StatCell( - label = "Weather", - value = WeatherService.describeCode(current?.weatherCode), + label = stringResource(R.string.session_weather), + value = stringResource(WeatherService.describeCodeRes(current?.weatherCode)), size = StatCellSize.Small ) StatCell( - label = "Surface", + label = stringResource(R.string.session_surface), value = if (WeatherService.isWet( current?.weatherPrecipitationMm, current?.weatherCode ) - ) "WET" else "DRY", + ) stringResource(R.string.session_wet) else stringResource(R.string.session_dry), size = StatCellSize.Small ) } @@ -309,139 +321,104 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp) + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) ) { - DragMetricCard(DragMetricDisplay("TOP SPEED", if (maxSpeed > 0) UnitFormatter.formatSpeed(maxSpeed, useMetric) else "—", UnitFormatter.speedUnitLabel(useMetric), maxSpeed > 0), modifier = Modifier.weight(1f)) - DragMetricCard(DragMetricDisplay("AVG SPEED", if (avgSpeed > 0) UnitFormatter.formatSpeed(avgSpeed, useMetric) else "—", UnitFormatter.speedUnitLabel(useMetric), avgSpeed > 0), modifier = Modifier.weight(1f)) - DragMetricCard(DragMetricDisplay("MAX ACCEL", if (maxAcceleration > 0) String.format(Locale.US, "%.1f", UnitFormatter.convertSpeed(maxAcceleration, useMetric)) else "—", "${UnitFormatter.speedUnitLabel(useMetric)}/S", maxAcceleration > 0), modifier = Modifier.weight(1f)) + DragMetricCard(DragMetricDisplay(stringResource(R.string.car_top_speed), if (maxSpeed > 0) UnitFormatter.formatSpeed(maxSpeed, useMetric) else "—", UnitFormatter.speedUnitLabel(useMetric), maxSpeed > 0), modifier = Modifier.weight(1f)) + DragMetricCard(DragMetricDisplay(stringResource(R.string.lap_avg_speed), if (avgSpeed > 0) UnitFormatter.formatSpeed(avgSpeed, useMetric) else "—", UnitFormatter.speedUnitLabel(useMetric), avgSpeed > 0), modifier = Modifier.weight(1f)) + DragMetricCard(DragMetricDisplay(stringResource(R.string.drag_max_accel), if (maxAcceleration > 0) String.format(Locale.US, "%.1f", UnitFormatter.convertSpeed(maxAcceleration, useMetric)) else "—", "${UnitFormatter.speedUnitLabel(useMetric)}/s", maxAcceleration > 0), modifier = Modifier.weight(1f)) } Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp) + horizontalArrangement = Arrangement.spacedBy(Spacing.sm) ) { val hasElevation = elevationGain != 0.0 || elevationLoss != 0.0 val netLabel = if (elevationNet >= 0) String.format(Locale.US, "+%.0f m", elevationNet) else String.format(Locale.US, "%.0f m", elevationNet) val gainLabel = "+%.0f m".format(elevationGain) val lossLabel = String.format(Locale.US, "%.0f m", elevationLoss) - DragMetricCard(DragMetricDisplay("ELEV NET", if (hasElevation) netLabel else "—", "", hasElevation), modifier = Modifier.weight(1f)) - DragMetricCard(DragMetricDisplay("ELEV ↑", if (hasElevation) gainLabel else "—", "", hasElevation), modifier = Modifier.weight(1f)) - DragMetricCard(DragMetricDisplay("ELEV ↓", if (hasElevation) lossLabel else "—", "", hasElevation), modifier = Modifier.weight(1f)) + DragMetricCard(DragMetricDisplay(stringResource(R.string.drag_net_elev), if (hasElevation) netLabel else "—", "", hasElevation), modifier = Modifier.weight(1f)) + DragMetricCard(DragMetricDisplay(stringResource(R.string.drag_climb), if (hasElevation) gainLabel else "—", "", hasElevation), modifier = Modifier.weight(1f)) + DragMetricCard(DragMetricDisplay(stringResource(R.string.drag_descent), if (hasElevation) lossLabel else "—", "", hasElevation), modifier = Modifier.weight(1f)) } // Labels come from the splits themselves, so a tile can never name a milestone // the timer was not measuring. Three standing splits fill the first row; the // longest rolling split shares the second with the two mile figures. - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { metrics.standing.take(3).forEach { split -> DragMetricCard( - DragMetricDisplay(split.label, formatMetric(split.seconds), "SEC", split.seconds != null), + DragMetricDisplay(split.label, formatMetric(split.seconds), "s", split.seconds != null), modifier = Modifier.weight(1f) ) } } - Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { metrics.rolling.lastOrNull()?.let { split -> DragMetricCard( - DragMetricDisplay(split.label, formatMetric(split.seconds), "SEC", split.seconds != null), + DragMetricDisplay(split.label, formatMetric(split.seconds), "s", split.seconds != null), modifier = Modifier.weight(1f) ) } - DragMetricCard(DragMetricDisplay("\u00bc MILE", formatMetric(metrics.quarterMileTime), "SEC", metrics.quarterMileTime != null), modifier = Modifier.weight(1f)) - DragMetricCard(DragMetricDisplay("TRAP SPD", metrics.quarterMileSpeed?.let { UnitFormatter.formatSpeed(it, useMetric) } ?: "\u2014", UnitFormatter.speedUnitLabel(useMetric), metrics.quarterMileSpeed != null), modifier = Modifier.weight(1f)) + DragMetricCard(DragMetricDisplay(stringResource(R.string.drag_quarter_mile), formatMetric(metrics.quarterMileTime), "s", metrics.quarterMileTime != null), modifier = Modifier.weight(1f)) + DragMetricCard(DragMetricDisplay(stringResource(R.string.drag_trap_speed), metrics.quarterMileSpeed?.let { UnitFormatter.formatSpeed(it, useMetric) } ?: "\u2014", UnitFormatter.speedUnitLabel(useMetric), metrics.quarterMileSpeed != null), modifier = Modifier.weight(1f)) } } - Divider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) - - // ── Chart header ───────────────────────────────────── + // ── Chart card ───────────────────────────────────── + Column( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(start = Spacing.gutter, end = Spacing.gutter, top = Spacing.md, bottom = Spacing.gutter) + .paddockCard() + ) { Row( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.bgCard) - .padding(horizontal = Spacing.md, vertical = 6.dp), + .padding(start = Spacing.lg, end = Spacing.sm, top = Spacing.sm, bottom = Spacing.sm), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { Text( - text = if (showMap) "GPS Trace" else "Speed Trace", - style = TrackProType.label, - color = TrackProTheme.colors.textMuted + text = if (showMap) stringResource(R.string.drag_gps_trace) else stringResource(R.string.drag_speed), + style = TrackProType.titleMedium, + color = TrackProTheme.colors.textPrimary ) Row( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically ) { if (!showMap) { - listOf(true to "Meter", false to "Sec").forEach { (isMeters, label) -> - val active = xAxisInMeters == isMeters - Box( - modifier = Modifier - .pressable( - onClick = { xAxisInMeters = isMeters }, - scale = 0.96f, - haptic = Haptic.Selection - ) - .background( - if (active) TrackProTheme.colors.accent else TrackProTheme.colors.sectorLine, - RoundedCornerShape(0.dp) - ) - .padding(horizontal = 8.dp, vertical = 3.dp) - ) { - Text( - text = label, - style = TrackProType.body.atSize(9.sp), - color = if (active) TrackProTheme.colors.onAccent else TrackProTheme.colors.textMuted - ) - } + listOf(true to "m", false to "s").forEach { (isMeters, label) -> + TraceToggle(label = label, active = xAxisInMeters == isMeters) { xAxisInMeters = isMeters } } + Spacer(Modifier.width(Spacing.xs)) } - listOf(false to "Chart", true to "Map").forEach { (isMap, label) -> - val active = showMap == isMap - Box( - modifier = Modifier - .pressable( - onClick = { showMap = isMap }, - scale = 0.96f, - haptic = Haptic.Selection - ) - .background( - if (active) TrackProTheme.colors.accent else TrackProTheme.colors.sectorLine, - RoundedCornerShape(0.dp) - ) - .padding(horizontal = 8.dp, vertical = 3.dp) - ) { - Text( - text = label, - style = TrackProType.body.atSize(9.sp), - color = if (active) TrackProTheme.colors.onAccent else TrackProTheme.colors.textMuted - ) - } + listOf(false to stringResource(R.string.drag_chart), true to stringResource(R.string.drag_map)).forEach { (isMap, label) -> + TraceToggle(label = label, active = showMap == isMap) { showMap = isMap } } } } - Divider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) - Box( modifier = Modifier .fillMaxSize() - .background(TrackProTheme.colors.bgCard) + .clip(RoundedCornerShape(bottomStart = 24.dp, bottomEnd = 24.dp)) ) { if (showMap) { if (mapGpsData.isNotEmpty()) { DragSessionMapView(gpsData = mapGpsData, modifier = Modifier.fillMaxSize()) } else { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("No GPS data", style = TrackProType.label, color = TrackProTheme.colors.markingDim) + Text(stringResource(R.string.drag_no_gps), style = TrackProType.label, color = TrackProTheme.colors.markingDim) } } } else if (traceState != TraceState.Loaded) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text( - text = if (traceState == TraceState.Loading) "LOADING TRACE" - else "NO GPS TRACE RECORDED FOR THIS RUN", + text = if (traceState == TraceState.Loading) stringResource(R.string.drag_loading_trace) + else stringResource(R.string.drag_no_trace), style = TrackProType.label, color = TrackProTheme.colors.markingDim ) @@ -479,10 +456,32 @@ fun GraphScreen(onBack: () -> Unit, sessionId: Long) { }, modifier = Modifier .fillMaxSize() - .padding(8.dp) + .padding(Spacing.sm) ) } } + } + } +} + +/** A small pill toggle in the chart card's header. */ +@Composable +private fun TraceToggle(label: String, active: Boolean, onClick: () -> Unit) { + Box( + modifier = Modifier + .heightIn(min = 44.dp) + .clip(TrackProShapes.pill) + .pressable(onClick = onClick, scale = 0.96f, haptic = Haptic.Selection) + .background(if (active) TrackProTheme.colors.accent else TrackProTheme.colors.bgElevated) + .semantics { selected = active } + .padding(horizontal = Spacing.md), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + style = TrackProType.label, + color = if (active) TrackProTheme.colors.onAccent else TrackProTheme.colors.textMuted + ) } } @@ -602,8 +601,8 @@ private fun LineChart.setupChartStyle() { axisRight.isEnabled = false description.isEnabled = false legend.isEnabled = false - setNoDataText("Calculating data...") - setNoDataTextColor(android.graphics.Color.WHITE) + setNoDataText(context.getString(R.string.drag_calculating)) + setNoDataTextColor(android.graphics.Color.parseColor(DataVizColors.chartAxisText)) setBackgroundColor(android.graphics.Color.parseColor(DataVizColors.chartBackground)) } diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/LapDetailScreen.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/LapDetailScreen.kt index d49d6b0..fa57d8a 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/LapDetailScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/LapDetailScreen.kt @@ -1,5 +1,7 @@ package com.example.trackpro.screens.listViewScreens.lapDetail +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -37,7 +39,8 @@ import com.example.trackpro.theme.markingDim import com.example.trackpro.components.pressable import com.example.trackpro.components.AppTopBar import com.example.trackpro.components.DraggableSheet -import com.example.trackpro.components.SectionLabel +import com.example.trackpro.components.paddockCard +import androidx.compose.foundation.layout.navigationBarsPadding import com.example.trackpro.theme.atSize import com.example.trackpro.theme.DataVizColors import com.example.trackpro.theme.Spacing @@ -73,9 +76,9 @@ import androidx.compose.ui.semantics.selected // ── Heatmap mode ─────────────────────────────────────────── -enum class HeatmapMode(val label: String, val icon: String) { - SPEED("SPEED", "⚡"), - UNIFORM("LINE", "—") +enum class HeatmapMode(@androidx.annotation.StringRes val label: Int, val icon: String) { + SPEED(R.string.lap_mode_speed, "⚡"), + UNIFORM(R.string.lap_mode_line, "—") } // ── Activity ─────────────────────────────────────────────── @@ -171,7 +174,7 @@ fun LapDetailScreen( LoadingView() } else if (primaryLap == null) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Lap not found", style = TrackProType.label, color = TrackProTheme.colors.markingDim) + Text(stringResource(R.string.lap_not_found), style = TrackProType.label, color = TrackProTheme.colors.markingDim) } } else { val lap = primaryLap!! @@ -191,7 +194,7 @@ fun LapDetailScreen( .align(Alignment.TopCenter) ) { AppTopBar( - title = "Lap ${lap.lapnumber} · ${lap.laptime}", + title = stringResource(R.string.lap_title, lap.lapnumber, lap.laptime), accent = TrackProTheme.colors.accent, onBack = { navController.popBackStack() }, trailing = { @@ -206,13 +209,10 @@ fun LapDetailScreen( // Heatmap mode switcher Row( modifier = Modifier - .padding(horizontal = Spacing.md, vertical = Spacing.sm) - .background( - TrackProTheme.colors.bgCard.copy(alpha = 0.92f), - RoundedCornerShape(0.dp) - ) + .padding(horizontal = Spacing.gutter, vertical = Spacing.sm) + .paddockCard(shape = TrackProShapes.pill, color = TrackProTheme.colors.bgCard.copy(alpha = 0.94f)) .padding(4.dp), - horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { HeatmapMode.values().forEach { mode -> // isSelected, not selected: inside the semantics block the bare name @@ -229,18 +229,18 @@ fun LapDetailScreen( // that decides what the map is showing, and it gets used // one-thumbed with the other hand holding the phone. .heightIn(min = 48.dp) - .clip(RoundedCornerShape(0.dp)) + .clip(TrackProShapes.pill) .background( if (isSelected) TrackProTheme.colors.accent else Color.Transparent ) .semantics { selected = isSelected } - .padding(horizontal = Spacing.md, vertical = 6.dp), + .padding(horizontal = Spacing.lg, vertical = Spacing.sm), contentAlignment = Alignment.Center ) { Text( - mode.label, - style = TrackProType.label, + stringResource(mode.label), + style = TrackProType.titleMedium, color = if (isSelected) TrackProTheme.colors.onAccent else TrackProTheme.colors.textMuted ) } @@ -253,13 +253,10 @@ fun LapDetailScreen( Box( modifier = Modifier .align(Alignment.TopEnd) - .padding(top = 80.dp, end = 16.dp) - .background( - TrackProTheme.colors.bgCard.copy(alpha = 0.92f), - RoundedCornerShape(0.dp) - ) - .border(1.dp, COMPARE_COLOR.copy(alpha = 0.5f), RoundedCornerShape(0.dp)) - .padding(horizontal = 12.dp, vertical = 8.dp) + .padding(top = 136.dp, end = Spacing.gutter) + .paddockCard(shape = TrackProShapes.control, color = TrackProTheme.colors.bgCard.copy(alpha = 0.94f)) + .border(1.dp, COMPARE_COLOR.copy(alpha = 0.5f), TrackProShapes.control) + .padding(start = Spacing.md, top = Spacing.sm, bottom = Spacing.sm) ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -271,19 +268,19 @@ fun LapDetailScreen( .background(COMPARE_COLOR, CircleShape) ) Column { - Text("Compare · Lap ${cl.lapnumber}", style = TrackProType.label.atSize(8.sp), color = COMPARE_COLOR) - Text(cl.laptime, style = TrackProType.titleMedium.atSize(13.sp), color = TrackProTheme.colors.textPrimary) + Text(stringResource(R.string.lap_compare_with_n, cl.lapnumber), style = TrackProType.label, color = COMPARE_COLOR) + Text(cl.laptime, style = TrackProType.titleMedium, color = TrackProTheme.colors.textPrimary) if (compareMs > 0) { Text( text = deltaMs.toLapDeltaString(), - style = TrackProType.body.atSize(10.sp), + style = TrackProType.label, color = deltaColor(deltaMs) ) } } Icon( imageVector = Icons.Default.Close, - contentDescription = "Remove compare", + contentDescription = stringResource(R.string.lap_remove_compare), tint = TrackProTheme.colors.textMuted, modifier = Modifier .minimumInteractiveComponentSize() @@ -294,7 +291,7 @@ fun LapDetailScreen( }, scale = 0.90f ) - .size(16.dp) + .size(20.dp) ) } } @@ -327,23 +324,32 @@ fun LapDetailScreen( } // Action buttons row + // An opaque dock, not floating buttons: a dismissed sheet parks directly + // behind this row, so the row has to cover it. Square on top while the + // stats sheet is open, so the two read as one surface. + val dockCorner = if (showStatsPanel) 0.dp else 28.dp Row( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.bgCard.copy(alpha = 0.95f)) - .padding(horizontal = Spacing.md, vertical = Spacing.sm), - horizontalArrangement = Arrangement.spacedBy(Spacing.sm) + .background( + TrackProTheme.colors.bgCard, + RoundedCornerShape(topStart = dockCorner, topEnd = dockCorner) + ) + .navigationBarsPadding() + .padding(horizontal = Spacing.gutter, vertical = Spacing.md), + horizontalArrangement = Arrangement.spacedBy(Spacing.md) ) { // Stats toggle ActionButton( - label = if (showStatsPanel) "Hide Stats" else "Stats", + label = if (showStatsPanel) stringResource(R.string.lap_hide_stats) else stringResource(R.string.lap_stats), color = TrackProTheme.colors.accent, + filled = true, modifier = Modifier.weight(1f) ) { showStatsPanel = !showStatsPanel } // Compare toggle ActionButton( - label = if (compareLap != null) "Comparing" else "Compare", + label = if (compareLap != null) stringResource(R.string.lap_comparing) else stringResource(R.string.lap_compare), color = if (compareLap != null) COMPARE_COLOR else TrackProTheme.colors.accent, modifier = Modifier.weight(1f) ) { showLapPicker = true } @@ -610,19 +616,17 @@ private fun StatsPanel( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - SectionLabel("Lap Stats") + Text(stringResource(R.string.lap_stats), style = TrackProType.titleLarge, color = TrackProTheme.colors.textPrimary) Icon( - Icons.Default.Close, "dismiss", + Icons.Default.Close, stringResource(R.string.common_close), tint = TrackProTheme.colors.textMuted, modifier = Modifier .minimumInteractiveComponentSize() .pressable(onClick = onDismiss, scale = 0.90f) - .size(16.dp) + .size(20.dp) ) } - HorizontalDivider(color = TrackProTheme.colors.sectorLine) - // Header row Row( modifier = Modifier @@ -630,15 +634,15 @@ private fun StatsPanel( .padding(horizontal = Spacing.lg, vertical = Spacing.sm), horizontalArrangement = Arrangement.SpaceBetween ) { - Text("Metric", style = TrackProType.label.atSize(8.sp), color = TrackProTheme.colors.textMuted, + Text("", style = TrackProType.label, color = TrackProTheme.colors.textMuted, modifier = Modifier.weight(1.4f)) Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - Box(Modifier.size(6.dp).background(PRIMARY_COLOR, CircleShape)) - Text("Lap ${primaryLap.lapnumber}", style = TrackProType.label.atSize(8.sp), color = PRIMARY_COLOR) + Box(Modifier.size(8.dp).background(PRIMARY_COLOR, CircleShape)) + Text(stringResource(R.string.lap_number, primaryLap.lapnumber), style = TrackProType.label, color = PRIMARY_COLOR) } if (compareLap != null) { Row( @@ -646,39 +650,43 @@ private fun StatsPanel( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { - Box(Modifier.size(6.dp).background(COMPARE_COLOR, CircleShape)) - Text("Lap ${compareLap.lapnumber}", style = TrackProType.label.atSize(8.sp), color = COMPARE_COLOR) + Box(Modifier.size(8.dp).background(COMPARE_COLOR, CircleShape)) + Text(stringResource(R.string.lap_number, compareLap.lapnumber), style = TrackProType.label, color = COMPARE_COLOR) } } } - HorizontalDivider(color = TrackProTheme.colors.sectorLine) + HorizontalDivider( + color = TrackProTheme.colors.sectorLine, + modifier = Modifier.padding(horizontal = Spacing.lg) + ) val speedUnit = UnitFormatter.speedUnitLabel(useMetric) // Resolved here rather than inside buildList: that lambda is inline with builder // inference, which is a poor place to read a CompositionLocal from. val deltaRowColor = deltaColor(deltaMs) + val res = LocalContext.current.resources val rows = buildList { - add(StatRow("Lap Time", primaryLap.laptime, compareLap?.laptime ?: "—")) - add(StatRow("Top Speed", "${UnitFormatter.formatSpeed(primaryTopSpeed, useMetric)} $speedUnit", + add(StatRow(res.getString(R.string.lap_time), primaryLap.laptime, compareLap?.laptime ?: "—")) + add(StatRow(res.getString(R.string.car_top_speed), "${UnitFormatter.formatSpeed(primaryTopSpeed, useMetric)} $speedUnit", if (compareLap != null) "${UnitFormatter.formatSpeed(compareTopSpeed, useMetric)} $speedUnit" else "—")) - add(StatRow("Avg Speed", "${UnitFormatter.formatSpeedPrecise(primaryAvgSpd.toDouble(), useMetric)} $speedUnit", + add(StatRow(res.getString(R.string.lap_avg_speed), "${UnitFormatter.formatSpeedPrecise(primaryAvgSpd.toDouble(), useMetric)} $speedUnit", if (compareLap != null) "${UnitFormatter.formatSpeedPrecise(compareAvgSpd.toDouble(), useMetric)} $speedUnit" else "—")) - add(StatRow("GPS Points", "${primaryGps.size}", + add(StatRow(res.getString(R.string.lap_gps_points), "${primaryGps.size}", if (compareLap != null) "${compareGps.size}" else "—")) primarySectors.sortedBy { it.sectorIndex }.forEach { sector -> val compareSplit = compareSectors.find { it.sectorIndex == sector.sectorIndex } add(StatRow( - "Sector ${sector.sectorIndex + 1}", - String.format(Locale.US, "%.2fs", sector.splitTimeMs / 1000.0), - if (compareLap != null) compareSplit?.let { String.format(Locale.US, "%.2fs", it.splitTimeMs / 1000.0) } ?: "—" else "—" + res.getString(R.string.lap_sector_n, sector.sectorIndex + 1), + String.format(Locale.US, "%.2f s", sector.splitTimeMs / 1000.0), + if (compareLap != null) compareSplit?.let { String.format(Locale.US, "%.2f s", it.splitTimeMs / 1000.0) } ?: "—" else "—" )) } if (compareLap != null) { // deltaMs describes the compare lap, so it belongs in the compare column - // in the primary column it read as the delta of the lap being viewed, which // inverted its meaning and coloured a slower lap green. - add(StatRow("Delta", "—", deltaMs.toLapDeltaString(), compareColor = deltaRowColor)) + add(StatRow(res.getString(R.string.lap_delta), "—", deltaMs.toLapDeltaString(), compareColor = deltaRowColor)) } } @@ -686,27 +694,26 @@ private fun StatsPanel( Row( modifier = Modifier .fillMaxWidth() - .background( - if (i % 2 == 0) Color.Transparent - else TrackProTheme.colors.bgElevated.copy(alpha = 0.5f) - ) - .padding(horizontal = Spacing.lg, vertical = Spacing.sm), + .padding(horizontal = Spacing.lg, vertical = Spacing.md), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text(row.label.uppercase(), style = TrackProType.label.atSize(9.sp), color = TrackProTheme.colors.textMuted, + Text(row.label, style = TrackProType.body, color = TrackProTheme.colors.textMuted, modifier = Modifier.weight(1.4f)) - Text(row.primary, style = TrackProType.body.atSize(13.sp), + Text(row.primary, style = TrackProType.titleMedium, color = TrackProTheme.colors.textPrimary, modifier = Modifier.weight(1f)) if (compareLap != null && row.compare.isNotEmpty()) { - Text(row.compare, style = TrackProType.body.atSize(13.sp), + Text(row.compare, style = TrackProType.titleMedium, color = row.compareColor ?: TrackProTheme.colors.textPrimary, modifier = Modifier.weight(1f)) } } if (i < rows.lastIndex) { - HorizontalDivider(color = TrackProTheme.colors.sectorLine.copy(alpha = 0.5f)) + HorizontalDivider( + color = TrackProTheme.colors.sectorLine, + modifier = Modifier.padding(horizontal = Spacing.lg) + ) } } @@ -763,16 +770,15 @@ private fun LapPickerSheet( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - SectionLabel("Select Lap to Compare") - Icon(Icons.Default.Close, "close", tint = TrackProTheme.colors.textMuted, + Text(stringResource(R.string.lap_compare_with), style = TrackProType.titleLarge, color = TrackProTheme.colors.textPrimary) + Icon(Icons.Default.Close, stringResource(R.string.common_close), tint = TrackProTheme.colors.textMuted, modifier = Modifier .minimumInteractiveComponentSize() .pressable(onClick = onDismiss, scale = 0.90f) - .size(16.dp)) + .size(20.dp)) } - HorizontalDivider(color = TrackProTheme.colors.sectorLine) - LazyColumn(contentPadding = PaddingValues(vertical = 4.dp, horizontal = Spacing.sm)) { + LazyColumn(contentPadding = PaddingValues(vertical = 4.dp, horizontal = Spacing.md)) { items(timedLaps) { (lap, lapMs) -> val deltaMs = lapMs - primaryLapMs val isSelected = lap.id == selectedLap?.id @@ -781,26 +787,25 @@ private fun LapPickerSheet( Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 3.dp) + .padding(vertical = 4.dp) + .clip(TrackProShapes.control) .pressable(onClick = { onSelect(lap) }, haptic = Haptic.Selection) - .clip(RoundedCornerShape(0.dp)) + .heightIn(min = 56.dp) .background( when { isSelected -> COMPARE_COLOR.copy(alpha = 0.15f) - isBest -> TrackProTheme.colors.accent.copy(alpha = 0.05f) - else -> Color.Transparent + else -> TrackProTheme.colors.bgElevated } ) .border( width = 1.dp, color = when { isSelected -> COMPARE_COLOR.copy(alpha = 0.5f) - isBest -> TrackProTheme.colors.accent.copy(alpha = 0.2f) else -> Color.Transparent }, - shape = RoundedCornerShape(0.dp) + shape = TrackProShapes.control ) - .padding(horizontal = Spacing.md, vertical = Spacing.sm), + .padding(horizontal = Spacing.lg, vertical = Spacing.sm), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween ) { @@ -809,17 +814,17 @@ private fun LapPickerSheet( horizontalArrangement = Arrangement.spacedBy(Spacing.sm) ) { Text( - String.format(Locale.US, "%02d", lap.lapnumber), - style = TrackProType.statValue.atSize(16.sp), - color = if (isBest) TrackProTheme.colors.accent else TrackProTheme.colors.textPrimary + stringResource(R.string.lap_number, lap.lapnumber), + style = TrackProType.titleMedium, + color = TrackProTheme.colors.textPrimary ) if (isBest) { Box( Modifier - .background(TrackProTheme.colors.accent.copy(alpha = 0.15f), TrackProShapes.badge) - .padding(horizontal = 5.dp, vertical = 2.dp) + .background(TrackProTheme.colors.deltaGood.copy(alpha = 0.14f), TrackProShapes.badge) + .padding(horizontal = Spacing.sm, vertical = 2.dp) ) { - Text("Best", style = TrackProType.label.atSize(7.sp), color = TrackProTheme.colors.accent) + Text(stringResource(R.string.lap_badge_best), style = TrackProType.label, color = TrackProTheme.colors.deltaGood) } } } @@ -830,10 +835,10 @@ private fun LapPickerSheet( // Delta vs primary Text( deltaMs.toLapDeltaString(), - style = TrackProType.body.atSize(11.sp), + style = TrackProType.label, color = deltaColor(deltaMs) ) - Text(lap.laptime, style = TrackProType.titleMedium.atSize(15.sp), color = TrackProTheme.colors.textPrimary) + Text(lap.laptime, style = TrackProType.titleMedium, color = TrackProTheme.colors.textPrimary) if (isSelected) { Box(Modifier.size(8.dp).background(COMPARE_COLOR, CircleShape)) } @@ -856,8 +861,8 @@ private fun LoadingView() { modifier = Modifier.size(32.dp), strokeWidth = 2.dp ) - Spacer(Modifier.height(12.dp)) - Text("Loading lap", style = TrackProType.label, color = TrackProTheme.colors.markingDim) + Spacer(Modifier.height(Spacing.md)) + Text(stringResource(R.string.lap_loading), style = TrackProType.label, color = TrackProTheme.colors.markingDim) } } } @@ -869,18 +874,25 @@ private fun ActionButton( label: String, color: Color, modifier: Modifier = Modifier, + filled: Boolean = false, onClick: () -> Unit ) { Box( modifier = modifier + .heightIn(min = 52.dp) + .paddockCard( + shape = TrackProShapes.control, + color = if (filled) color else TrackProTheme.colors.bgElevated + ) .pressable(onClick = onClick, scale = 0.96f) - .clip(RoundedCornerShape(0.dp)) - .background(color.copy(alpha = 0.12f)) - .border(1.dp, color.copy(alpha = 0.3f), RoundedCornerShape(0.dp)) - .padding(vertical = Spacing.sm), + .padding(horizontal = Spacing.lg, vertical = Spacing.md), contentAlignment = Alignment.Center ) { - Text(label, style = TrackProType.titleMedium.atSize(12.sp), color = color) + Text( + label, + style = TrackProType.titleMedium, + color = if (filled) TrackProTheme.colors.onAccent else color + ) } } diff --git a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/TimeAttackListItem.kt b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/TimeAttackListItem.kt index 2308e95..5670cea 100644 --- a/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/TimeAttackListItem.kt +++ b/app/src/main/java/com/example/trackpro/screens/listViewScreens/listItems/TimeAttackListItem.kt @@ -1,5 +1,8 @@ package com.example.trackpro.screens.listViewScreens.listItems +import com.example.trackpro.components.pluralResource +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -24,6 +27,16 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import com.example.trackpro.components.specLabel +import com.example.trackpro.components.SectionTitle +import com.example.trackpro.components.PaddockCard +import com.example.trackpro.components.IconCircle +import com.example.trackpro.components.DashGroup +import androidx.compose.material3.Icon +import androidx.compose.material.icons.filled.DirectionsCar +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.Icons +import androidx.compose.ui.draw.clip import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -142,14 +155,14 @@ fun TimeAttackListItemScreen( Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column(horizontalAlignment = Alignment.CenterHorizontally) { CircularProgressIndicator(color = TrackProTheme.colors.accent, - modifier = Modifier.size(36.dp), strokeWidth = 2.dp) - Spacer(Modifier.height(12.dp)) - Text("Loading session", style = TrackProType.label, color = TrackProTheme.colors.markingDim) + modifier = Modifier.size(32.dp), strokeWidth = 2.dp) + Spacer(Modifier.height(Spacing.md)) + Text(stringResource(R.string.session_loading), style = TrackProType.label, color = TrackProTheme.colors.markingDim) } } } else if (sessionData == null) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - Text("Session not found", style = TrackProType.label, color = TrackProTheme.colors.markingDim) + Text(stringResource(R.string.session_not_found), style = TrackProType.label, color = TrackProTheme.colors.markingDim) } } else { val session = sessionData!! @@ -225,15 +238,15 @@ fun TimeAttackListItemScreen( val secondHalfAvg = lapMillis.drop(half).average() val diff = secondHalfAvg - firstHalfAvg when { - diff < -500 -> "IMPROVING ↑" - diff > 500 -> "FADING ↓" - else -> "CONSISTENT →" + diff < -500 -> -1 + diff > 500 -> 1 + else -> 0 } - } else "—" - val trendColor = when { - trend.contains("IMPROVING") -> TrackProTheme.colors.deltaGood - trend.contains("FADING") -> TrackProTheme.colors.deltaBad - else -> TrackProTheme.colors.textMuted + } else null + val trendColor = when (trend) { + -1 -> TrackProTheme.colors.deltaGood + 1 -> TrackProTheme.colors.deltaBad + else -> TrackProTheme.colors.textMuted } LazyColumn( @@ -243,110 +256,100 @@ fun TimeAttackListItemScreen( // ── Top bar item { AppTopBar( - title = "Session Detail", + title = stringResource(R.string.session_title), onBack = { navController.popBackStack() }, accent = TrackProTheme.colors.accent, trailing = { - Text("${timedLaps.size} laps", style = TrackProType.label, color = TrackProTheme.colors.textMuted) + Text(pluralResource(R.plurals.count_laps, timedLaps.size), style = TrackProType.label, color = TrackProTheme.colors.textMuted) } ) } // ── Session + vehicle header item { - Column( - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.bgCard) - .padding(horizontal = Spacing.lg, vertical = Spacing.md), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { + PaddockCard(modifier = Modifier.padding(horizontal = Spacing.gutter)) { if (session.voided) { VoidStamp() - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(Spacing.sm)) } Text( text = session.eventType, - // The session leads its own readouts. - style = TrackProType.titleLarge.atSize(26.sp), + style = TrackProType.titleLarge, color = TrackProTheme.colors.textPrimary ) - Spacer(Modifier.height(6.dp)) - if (vehicle != null) { - Text( - text = "${vehicle.manufacturer} ${vehicle.model} (${vehicle.year})".uppercase(), - style = TrackProType.label, - color = TrackProTheme.colors.textMuted - ) - Text( - text = "${vehicle.engineType} · ${vehicle.horsepower}hp · ${vehicle.drivetrain}", - style = TrackProType.body.atSize(11.sp), - color = TrackProTheme.colors.textMuted.copy(alpha = 0.7f) - ) - } - Spacer(Modifier.height(4.dp)) Text( text = DateFormatterUtil.getDateTimeFormat().format(Date(session.startTime)), - style = TrackProType.body.atSize(11.sp), + style = TrackProType.label, color = TrackProTheme.colors.textMuted ) + if (vehicle != null) { + Spacer(Modifier.height(Spacing.md)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(TrackProShapes.control) + .background(TrackProTheme.colors.bgElevated) + .padding(horizontal = Spacing.md, vertical = Spacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + IconCircle(Icons.Filled.DirectionsCar, size = 32.dp) + Spacer(Modifier.width(Spacing.md)) + Column(Modifier.weight(1f)) { + Text( + text = "${vehicle.manufacturer} ${vehicle.model} (${vehicle.year})", + style = TrackProType.titleMedium, + color = TrackProTheme.colors.textPrimary + ) + Text( + text = "${specLabel(vehicle.engineType)} · ${vehicle.horsepower} hp · ${vehicle.drivetrain}", + style = TrackProType.label, + color = TrackProTheme.colors.textMuted + ) + } + } + } } - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) } // ── Key performance metrics item { - SectionLabel("Key Metrics", modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm)) - Row( - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.bgCard) - .padding(horizontal = Spacing.lg, vertical = Spacing.md), - horizontalArrangement = Arrangement.SpaceBetween - ) { - StatCell(label = "Best Lap", value = bestLap?.laptime ?: "—", valueColor = TrackProTheme.colors.deltaGood, size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally) - StatCell(label = "Average", value = avgMs.toLapTimeString(), size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally) - StatCell( - label = "Worst", - value = worstMs.toLapTimeString(), - valueColor = if (lapMillis.size > 1) TrackProTheme.colors.deltaBad else TrackProTheme.colors.textPrimary, - size = StatCellSize.Large, - horizontalAlignment = Alignment.CenterHorizontally - ) + DashGroup(stringResource(R.string.session_key_metrics)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + StatCell(label = stringResource(R.string.session_best_lap), value = bestLap?.laptime ?: "—", valueColor = TrackProTheme.colors.deltaGood, size = StatCellSize.Large) + StatCell(label = stringResource(R.string.session_average), value = avgMs.toLapTimeString(), size = StatCellSize.Large, horizontalAlignment = Alignment.CenterHorizontally) + StatCell( + label = stringResource(R.string.session_slowest), + value = worstMs.toLapTimeString(), + valueColor = if (worstMs > bestMs) TrackProTheme.colors.deltaBad else TrackProTheme.colors.textPrimary, + size = StatCellSize.Large, + horizontalAlignment = Alignment.End + ) + } } - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) } // ── Predicted best (theoretical lap) if (predictedBestMs != null) { item { - SectionLabel( - "Predicted Best", - modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm) - ) - Column( - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.bgCard) - .padding(horizontal = Spacing.lg, vertical = Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { + DashGroup(stringResource(R.string.session_predicted_best)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { StatCell( - label = "Theoretical Lap", + label = stringResource(R.string.session_theoretical_lap), value = predictedBestMs.toLapTimeString(), valueColor = TrackProTheme.colors.accent, size = StatCellSize.Large ) - StatCellDivider() StatCell( - label = "Time On Table", + label = stringResource(R.string.session_time_on_table), value = timeOnTableMs - ?.let { String.format(Locale.US, "-%.2fs", it / 1000.0) } ?: "—", + ?.let { String.format(Locale.US, "-%.2f s", it / 1000.0) } ?: "—", valueColor = if (timeOnTableMs != null) TrackProTheme.colors.deltaGood else TrackProTheme.colors.textMuted, @@ -354,168 +357,129 @@ fun TimeAttackListItemScreen( horizontalAlignment = Alignment.End ) } + Spacer(Modifier.height(Spacing.md)) Text( - text = "Best sector from every lap, combined. Based on " + - "${completeLapSectors.size} of ${timedLaps.size} laps " + - "with a full set of splits.", - style = TrackProType.body.atSize(11.sp), + text = stringResource(R.string.session_predicted_hint, completeLapSectors.size, timedLaps.size), + style = TrackProType.label, color = TrackProTheme.colors.markingDim ) } - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) } } - // ── Session stats row + // ── Session stats item { - SectionLabel("Session Stats", modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm)) - Column( - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.bgCard) - .padding(horizontal = Spacing.lg, vertical = Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { - StatRowItem( - label = "Total Session Time", - value = sessionDuration.toLapTimeString(), - textPrimary = TrackProTheme.colors.textPrimary, - textMuted = TrackProTheme.colors.textMuted - ) - StatRowItem( - label = "Top Speed (Session)", - value = "${UnitFormatter.formatSpeedPrecise(topSpeedOverall.toDouble(), useMetric)} ${UnitFormatter.speedUnitLabel(useMetric)}", - textPrimary = TrackProTheme.colors.textPrimary, - textMuted = TrackProTheme.colors.textMuted - ) - StatRowItem( - label = "Lap Count", - value = "${timedLaps.size}", - textPrimary = TrackProTheme.colors.textPrimary, - textMuted = TrackProTheme.colors.textMuted - ) - StatRowItem( - label = "Consistency (σ)", - value = consistency, - textPrimary = if (consistency != "—" && - consistency.replace("%","").toDoubleOrNull()?.let { it < 1.0 } == true) - TrackProTheme.colors.deltaGood else TrackProTheme.colors.textPrimary, - textMuted = TrackProTheme.colors.textMuted - ) - StatRowItem( - label = "Gap: Best → Worst", - value = if (lapMillis.size > 1) - "+${(worstMs - bestMs).toLapTimeString()}" else "—", - textPrimary = TrackProTheme.colors.textPrimary, - textMuted = TrackProTheme.colors.textMuted - ) - StatRowItem( - label = "Performance Trend", - value = trend, - textPrimary = trendColor, - textMuted = TrackProTheme.colors.textMuted - ) - } - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) - } - - // ── Conditions (only for sessions where weather was captured) - val capturedTempC = session.weatherTempC - if (capturedTempC != null) { - item { - val wet = WeatherService.isWet(session.weatherPrecipitationMm, session.weatherCode) - SectionLabel("Conditions", modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm)) - Column( - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.bgCard) - .padding(horizontal = Spacing.lg, vertical = Spacing.md), - verticalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { + DashGroup(stringResource(R.string.session_stats)) { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { + StatRowItem( + label = stringResource(R.string.session_total_time), + value = sessionDuration.toLapTimeString(), + textPrimary = TrackProTheme.colors.textPrimary, + textMuted = TrackProTheme.colors.textMuted + ) + StatRowItem( + label = stringResource(R.string.car_top_speed), + value = "${UnitFormatter.formatSpeedPrecise(topSpeedOverall.toDouble(), useMetric)} ${UnitFormatter.speedUnitLabel(useMetric)}", + textPrimary = TrackProTheme.colors.textPrimary, + textMuted = TrackProTheme.colors.textMuted + ) StatRowItem( - label = "Weather", - value = WeatherService.describeCode(session.weatherCode), + label = stringResource(R.string.session_laps), + value = "${timedLaps.size}", textPrimary = TrackProTheme.colors.textPrimary, textMuted = TrackProTheme.colors.textMuted ) StatRowItem( - label = "Surface", - value = if (wet) "WET" else "DRY", - // Wet vs dry is the single biggest caveat on any lap-time - // comparison, so it gets colour rather than blending in. - textPrimary = if (wet) TrackProTheme.colors.accent else TrackProTheme.colors.deltaGood, + label = stringResource(R.string.session_consistency), + value = consistency, + textPrimary = if (consistency != "—" && + consistency.replace("%","").toDoubleOrNull()?.let { it < 1.0 } == true) + TrackProTheme.colors.deltaGood else TrackProTheme.colors.textPrimary, textMuted = TrackProTheme.colors.textMuted ) StatRowItem( - label = "Air Temp", - value = UnitFormatter.formatTemperature(capturedTempC, useMetric), + label = stringResource(R.string.session_best_to_slowest), + value = if (lapMillis.size > 1) + "+${(worstMs - bestMs).toLapTimeString()}" else "—", textPrimary = TrackProTheme.colors.textPrimary, textMuted = TrackProTheme.colors.textMuted ) - session.weatherHumidityPct?.let { + StatRowItem( + label = stringResource(R.string.session_trend), + value = when (trend) { + -1 -> stringResource(R.string.trend_improving) + 1 -> stringResource(R.string.trend_fading) + 0 -> stringResource(R.string.trend_consistent) + else -> "—" + }, + textPrimary = trendColor, + textMuted = TrackProTheme.colors.textMuted + ) + } + } + } + + // ── Conditions (only for sessions where weather was captured) + val capturedTempC = session.weatherTempC + if (capturedTempC != null) { + item { + val wet = WeatherService.isWet(session.weatherPrecipitationMm, session.weatherCode) + DashGroup(stringResource(R.string.session_conditions)) { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { StatRowItem( - label = "Humidity", - value = "$it%", + label = stringResource(R.string.session_weather), + value = stringResource(WeatherService.describeCodeRes(session.weatherCode)), textPrimary = TrackProTheme.colors.textPrimary, textMuted = TrackProTheme.colors.textMuted ) - } - session.weatherWindKph?.let { wind -> StatRowItem( - label = "Wind", - value = "${UnitFormatter.formatSpeedPrecise(wind, useMetric)} " + - "${UnitFormatter.speedUnitLabel(useMetric)} " + - WeatherService.windCompass(session.weatherWindDirDeg), - textPrimary = TrackProTheme.colors.textPrimary, + label = stringResource(R.string.session_surface), + value = if (wet) stringResource(R.string.session_wet) else stringResource(R.string.session_dry), + // Wet vs dry is the single biggest caveat on any lap-time + // comparison, so it gets colour rather than blending in. + textPrimary = if (wet) TrackProTheme.colors.accent else TrackProTheme.colors.deltaGood, textMuted = TrackProTheme.colors.textMuted ) - } - session.weatherPressureHpa?.let { StatRowItem( - label = "Pressure", - value = String.format(Locale.US, "%.0f hPa", it), + label = stringResource(R.string.session_air_temp), + value = UnitFormatter.formatTemperature(capturedTempC, useMetric), textPrimary = TrackProTheme.colors.textPrimary, textMuted = TrackProTheme.colors.textMuted ) - } - } - HorizontalDivider(color = TrackProTheme.colors.sectorLine, thickness = 1.dp) - } - } - - // ── Void / restore - // Voiding is reversible by design: the run is kept, so putting it back has - // to be as easy as taking it out. A destructive-feeling action that cannot - // be undone would push people toward deleting instead, which is worse. - item { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Spacing.lg, vertical = Spacing.md) - ) { - DashAction( - label = if (session.voided) "Restore this session" else "Void this session", - detail = if (session.voided) - "Counts toward your bests again" - else "Kept and stamped, but stops counting toward your bests", - compact = true, - accent = if (session.voided) TrackProTheme.colors.accent - else TrackProTheme.colors.danger, - onClick = { - val target = !session.voided - scope.launch(Dispatchers.IO) { - database.sessionDataDao().setVoided(session.id, target) - val reloaded = database.sessionDataDao().getSessionById(sessionId) - withContext(Dispatchers.Main) { sessionData = reloaded } + session.weatherHumidityPct?.let { + StatRowItem( + label = stringResource(R.string.session_humidity), + value = "$it%", + textPrimary = TrackProTheme.colors.textPrimary, + textMuted = TrackProTheme.colors.textMuted + ) + } + session.weatherWindKph?.let { wind -> + StatRowItem( + label = stringResource(R.string.session_wind), + value = "${UnitFormatter.formatSpeedPrecise(wind, useMetric)} " + + "${UnitFormatter.speedUnitLabel(useMetric)} " + + WeatherService.windCompass(session.weatherWindDirDeg), + textPrimary = TrackProTheme.colors.textPrimary, + textMuted = TrackProTheme.colors.textMuted + ) + } + session.weatherPressureHpa?.let { + StatRowItem( + label = stringResource(R.string.session_pressure), + value = String.format(Locale.US, "%.0f hPa", it), + textPrimary = TrackProTheme.colors.textPrimary, + textMuted = TrackProTheme.colors.textMuted + ) } } - ) + } } } // ── Lap-by-lap breakdown item { - SectionLabel("Lap Breakdown", modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm)) + SectionTitle(stringResource(R.string.session_laps)) } if (timedLaps.isEmpty()) { @@ -526,7 +490,7 @@ fun TimeAttackListItemScreen( .padding(Spacing.xl), contentAlignment = Alignment.Center ) { - Text("No laps recorded", style = TrackProType.label, color = TrackProTheme.colors.markingDim) + Text(stringResource(R.string.session_no_timed_laps), style = TrackProType.label, color = TrackProTheme.colors.markingDim) } } } else { @@ -535,9 +499,14 @@ fun TimeAttackListItemScreen( val isWorst = lap.id == worstLap?.id && timedLaps.size > 1 val deltaMs = lapMs - bestMs val topSpeed = topSpeedPerLap[lap.lapnumber] ?: 0f - Box(modifier = Modifier.pressable(onClick = { - navController.navigate("lap_detail/$sessionId/${lap.id}") - })) { + Box( + modifier = Modifier + .padding(horizontal = Spacing.gutter, vertical = Spacing.xs) + .clip(TrackProShapes.control) + .pressable(onClick = { + navController.navigate("lap_detail/$sessionId/${lap.id}") + }, scale = 0.98f) + ) { LapRow( lap = lap, isBest = isBest, @@ -556,6 +525,36 @@ fun TimeAttackListItemScreen( } } } + + // ── Void / restore + // Voiding is reversible by design: the run is kept, so putting it back has + // to be as easy as taking it out. A destructive-feeling action that cannot + // be undone would push people toward deleting instead, which is worse. + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.gutter, vertical = Spacing.xl) + ) { + DashAction( + label = if (session.voided) stringResource(R.string.session_restore) else stringResource(R.string.session_void), + detail = if (session.voided) + stringResource(R.string.session_restore_hint) + else stringResource(R.string.session_void_hint), + compact = true, + accent = if (session.voided) TrackProTheme.colors.accent + else TrackProTheme.colors.danger, + onClick = { + val target = !session.voided + scope.launch(Dispatchers.IO) { + database.sessionDataDao().setVoided(session.id, target) + val reloaded = database.sessionDataDao().getSessionById(sessionId) + withContext(Dispatchers.Main) { sessionData = reloaded } + } + } + ) + } + } } } } @@ -585,106 +584,93 @@ private fun LapRow( else -> textMuted } val badge = when { - isBest -> "Best" - isWorst -> "Slow" - else -> "Lap" + isBest -> stringResource(R.string.lap_badge_best) + isWorst -> stringResource(R.string.session_slowest) + else -> null } - Box( + Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = Spacing.md, vertical = 4.dp) - .background( - if (isBest) goodColor.copy(alpha = 0.05f) else bgCard, - TrackProShapes.card - ) + .background(if (isBest) goodColor.copy(alpha = 0.08f) else bgCard, TrackProShapes.control) .border( - width = if (isBest) 1.dp else 0.dp, - color = if (isBest) goodColor.copy(alpha = 0.3f) else Color.Transparent, - shape = TrackProShapes.card + width = 1.dp, + color = if (isBest) goodColor.copy(alpha = 0.35f) else sectorLine, + shape = TrackProShapes.control ) + .padding(horizontal = Spacing.lg, vertical = Spacing.md), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = Spacing.md, vertical = Spacing.sm), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // Left: lap number + badge - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(Spacing.sm) - ) { - Text( - text = String.format(Locale.US, "%02d", lap.lapnumber), - style = TrackProType.statValue.atSize(18.sp), - color = accentColor - ) - Box( - modifier = Modifier - .background(accentColor.copy(alpha = 0.15f), TrackProShapes.badge) - .padding(horizontal = 6.dp, vertical = 2.dp) - ) { - Text(badge.uppercase(), style = TrackProType.label.atSize(8.sp), color = accentColor) + // Left: lap number + what makes it notable + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.lap_number, lap.lapnumber), + style = TrackProType.titleMedium, + color = textPrimary + ) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + if (badge != null) { + LapTag(badge, accentColor) } // GPS dropped out during this lap, so its time may be two laps merged into // one. Flagged rather than hidden - the driver knows whether it was. if (lap.signalGap) { - val fault = TrackProTheme.colors.danger - Box( - modifier = Modifier - .background(fault.copy(alpha = 0.15f), TrackProShapes.badge) - .padding(horizontal = 6.dp, vertical = 2.dp) - ) { - Text("GPS GAP", style = TrackProType.label.atSize(8.sp), color = fault) - } + LapTag(stringResource(R.string.lap_gps_gap), TrackProTheme.colors.danger) } - } - - // Center: top speed - StatCell( - label = "Top Speed", - value = if (topSpeed > 0) UnitFormatter.formatSpeed(topSpeed, useMetric) else "—", - unit = if (topSpeed > 0) UnitFormatter.speedUnitLabel(useMetric).lowercase() else null, - size = StatCellSize.Small, - horizontalAlignment = Alignment.CenterHorizontally - ) - - // Right: lap time + delta - Column(horizontalAlignment = Alignment.End) { - Text( - text = lap.laptime, - style = TrackProType.statValue.atSize(17.sp), - color = accentColor - ) - if (!isBest && deltaMs > 0) { - Text( - text = deltaMs.toLapDeltaString(), - style = TrackProType.body.atSize(11.sp), - color = badColor.copy(alpha = 0.8f) - ) - } else if (isBest) { + if (badge == null && !lap.signalGap) { Text( - text = "Reference", - style = TrackProType.label.atSize(9.sp), - color = goodColor.copy(alpha = 0.7f) + text = if (topSpeed > 0) stringResource(R.string.lap_top_speed, UnitFormatter.formatSpeed(topSpeed, useMetric), UnitFormatter.speedUnitLabel(useMetric)) else stringResource(R.string.lap_no_speed), + style = TrackProType.label, + color = textMuted ) } } } - // Left accent bar - Box( - modifier = Modifier - .align(Alignment.CenterStart) - .width(2.dp) - .height(36.dp) - .background(accentColor, RoundedCornerShape(topEnd = 2.dp, bottomEnd = 2.dp)) + // Right: lap time + delta + Column(horizontalAlignment = Alignment.End) { + Text( + text = lap.laptime, + style = TrackProType.statValue, + color = if (isBest) goodColor else textPrimary + ) + Text( + text = when { + isBest -> stringResource(R.string.lap_reference) + deltaMs > 0 -> deltaMs.toLapDeltaString() + else -> "—" + }, + style = TrackProType.label, + color = when { + isBest -> goodColor + deltaMs > 0 -> badColor + else -> textMuted + } + ) + } + Spacer(Modifier.width(Spacing.sm)) + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = TrackProTheme.colors.markingDim ) } } +@Composable +private fun LapTag(text: String, color: Color) { + Text( + text = text, + style = TrackProType.label, + color = color, + modifier = Modifier + .padding(top = 2.dp) + .background(color.copy(alpha = 0.14f), TrackProShapes.badge) + .padding(horizontal = Spacing.sm, vertical = 2.dp) + ) +} + @Composable private fun StatRowItem(label: String, value: String, textPrimary: Color, textMuted: Color) { Row( @@ -692,8 +678,8 @@ private fun StatRowItem(label: String, value: String, textPrimary: Color, textMu horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Text(label.uppercase(), style = TrackProType.label, color = textMuted) - Text(value, style = TrackProType.body, color = textPrimary) + Text(label, style = TrackProType.body, color = textMuted) + Text(value, style = TrackProType.titleMedium, color = textPrimary) } } diff --git a/app/src/main/java/com/example/trackpro/screens/profile/AccountScreens.kt b/app/src/main/java/com/example/trackpro/screens/profile/AccountScreens.kt index 8182185..ae7104d 100644 --- a/app/src/main/java/com/example/trackpro/screens/profile/AccountScreens.kt +++ b/app/src/main/java/com/example/trackpro/screens/profile/AccountScreens.kt @@ -1,5 +1,7 @@ package com.example.trackpro.screens.profile +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -19,6 +21,17 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import com.example.trackpro.online.MessageText +import com.example.trackpro.theme.Spacing +import com.example.trackpro.components.paddockCard +import com.example.trackpro.components.SectionTitle +import com.example.trackpro.components.PaddockCard +import com.example.trackpro.components.IconCircle +import androidx.compose.material3.Icon +import androidx.compose.material.icons.filled.CloudDone +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.Icons +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -33,12 +46,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.example.trackpro.TrackProApp -import com.example.trackpro.components.Bezel import com.example.trackpro.components.Haptic import com.example.trackpro.components.PhotoFrame import com.example.trackpro.components.PrimaryButton import com.example.trackpro.components.ScreenScaffold -import com.example.trackpro.components.SectionLabel import com.example.trackpro.components.ToggleChip import com.example.trackpro.components.initialsOf import com.example.trackpro.components.pressable @@ -70,53 +81,60 @@ fun AccountScreen(navController: NavController) { val serverUrl by online.settings.serverUrl.collectAsState() var editingServer by rememberSaveable { mutableStateOf(false) } - ScreenScaffold(title = "Account", onBack = { navController.popBackStack() }) { padding -> + ScreenScaffold(title = stringResource(R.string.profile_account), onBack = { navController.popBackStack() }) { padding -> Column( modifier = Modifier .fillMaxSize() .background(TrackProTheme.colors.panel) .verticalScroll(rememberScrollState()) .padding(top = padding.calculateTopPadding()) + .padding(horizontal = Spacing.gutter), + verticalArrangement = Arrangement.spacedBy(Spacing.md) ) { - Column( - modifier = Modifier.fillMaxWidth().background(TrackProTheme.colors.field).padding(14.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Text("TRACKBOARD", style = TrackProType.titleLarge.atSize(20.sp), color = TrackProTheme.colors.marking) - Benefit("Your garage and car photos, backed up and restored on a new phone") - Benefit("Your place on every track's leaderboard, next to your personal best") - Benefit("Optional. Recording never needs an account or a network") + PaddockCard { + Row(verticalAlignment = Alignment.CenterVertically) { + IconCircle(Icons.Filled.CloudDone, size = 48.dp) + Spacer(Modifier.width(Spacing.md)) + Column { + Text("TrackBoard", style = TrackProType.titleLarge, color = TrackProTheme.colors.marking) + Text(stringResource(R.string.account_optional), style = TrackProType.label, color = TrackProTheme.colors.markingDim) + } + } + Spacer(Modifier.height(Spacing.lg)) + Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { + Benefit(stringResource(R.string.account_benefit_backup)) + Benefit(stringResource(R.string.account_benefit_rank)) + Benefit(stringResource(R.string.account_benefit_optional)) + } + } + + PaddockCard { + AccountSignInForm( + notice = (account as? AccountState.SignedOut)?.notice, + serverSet = serverUrl.isNotBlank(), + onSignedIn = { navController.popBackStack() } + ) } - Bezel() // Most drivers never need to see the address; it is one tap away for those who do. Row( - modifier = Modifier.fillMaxWidth().background(TrackProTheme.colors.panel).padding(horizontal = 14.dp, vertical = 10.dp), + modifier = Modifier.fillMaxWidth().padding(start = Spacing.sm), verticalAlignment = Alignment.CenterVertically ) { Text( - text = "SERVER · ${serverUrl.removePrefix("https://").removePrefix("http://").ifBlank { "not set" }}", + text = stringResource(R.string.account_server, serverUrl.removePrefix("https://").removePrefix("http://").ifBlank { stringResource(R.string.account_not_set) }), style = TrackProType.label, color = TrackProTheme.colors.markingDim, modifier = Modifier.weight(1f) ) - ToggleChip(text = if (editingServer) "Done" else "Change", selected = false, onClick = { editingServer = !editingServer }) + ToggleChip(text = if (editingServer) stringResource(R.string.common_done) else stringResource(R.string.rig_change), selected = false, onClick = { editingServer = !editingServer }) } if (editingServer) { - Box(Modifier.padding(horizontal = 14.dp, vertical = 6.dp)) { + PaddockCard { ServerRow(url = serverUrl, onChange = online.settings::setServerUrl) } } - Bezel() - - Box(Modifier.background(TrackProTheme.colors.field).padding(14.dp)) { - AccountSignInForm( - notice = (account as? AccountState.SignedOut)?.notice, - serverSet = serverUrl.isNotBlank(), - onSignedIn = { navController.popBackStack() } - ) - } - Bezel() + Spacer(Modifier.height(Spacing.xl)) } } } @@ -124,9 +142,14 @@ fun AccountScreen(navController: NavController) { @Composable private fun Benefit(text: String) { Row(verticalAlignment = Alignment.Top) { - Box(Modifier.padding(top = 6.dp).size(width = 10.dp, height = 2.dp).background(TrackProTheme.colors.accent)) - Spacer(Modifier.width(10.dp)) - Text(text, style = TrackProType.body, color = TrackProTheme.colors.markingDim) + Icon( + Icons.Filled.CheckCircle, + contentDescription = null, + tint = TrackProTheme.colors.accent, + modifier = Modifier.padding(top = 2.dp).size(18.dp) + ) + Spacer(Modifier.width(Spacing.md)) + Text(text, style = TrackProType.body, color = TrackProTheme.colors.textMuted) } } @@ -155,16 +178,16 @@ fun EditProfileScreen(navController: NavController) { try { online.profile.setAvatar(uri) } catch (e: ApiException) { - error = e.message + error = MessageText.localize(e.message) } catch (e: NetworkException) { - error = "Changing your photo needs a connection. ${e.message}" + error = context.getString(R.string.account_photo_needs_connection, MessageText.localize(e.message) ?: "") } finally { photoBusy = false } } } - ScreenScaffold(title = "Edit profile", onBack = { navController.popBackStack() }) { padding -> + ScreenScaffold(title = stringResource(R.string.profile_edit), onBack = { navController.popBackStack() }) { padding -> Column( modifier = Modifier .fillMaxSize() @@ -172,28 +195,31 @@ fun EditProfileScreen(navController: NavController) { .verticalScroll(rememberScrollState()) .padding(top = padding.calculateTopPadding()) ) { - Row( - modifier = Modifier.fillMaxWidth().background(TrackProTheme.colors.field).padding(14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - PhotoFrame( - file = online.photos.file(snapshot?.avatarFile), - contentDescription = "Profile photo", - initials = initialsOf(name.ifBlank { "Driver" }), - modifier = Modifier.size(112.dp) - ) - Spacer(Modifier.width(14.dp)) - Column(verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.weight(1f)) { + PaddockCard(modifier = Modifier.padding(horizontal = Spacing.gutter)) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(Spacing.md) + ) { + PhotoFrame( + file = online.photos.file(snapshot?.avatarFile), + contentDescription = stringResource(R.string.profile_photo), + initials = initialsOf(name.ifBlank { stringResource(R.string.drive_driver) }), + shape = CircleShape, + modifier = Modifier.size(112.dp) + ) Text( - if (photoBusy) "UPLOADING…" else "PROFILE PHOTO", + if (photoBusy) stringResource(R.string.account_uploading) else stringResource(R.string.profile_photo), style = TrackProType.label, color = TrackProTheme.colors.markingDim ) - ToggleChip(text = "Take photo", selected = false, onClick = { if (!photoBusy) picker.fromCamera() }, modifier = Modifier.fillMaxWidth()) - ToggleChip(text = "Choose photo", selected = false, onClick = { if (!photoBusy) picker.fromGallery() }, modifier = Modifier.fillMaxWidth()) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.fillMaxWidth()) { + ToggleChip(text = stringResource(R.string.photo_take), selected = false, onClick = { if (!photoBusy) picker.fromCamera() }, modifier = Modifier.weight(1f)) + ToggleChip(text = stringResource(R.string.account_choose_photo), selected = false, onClick = { if (!photoBusy) picker.fromGallery() }, modifier = Modifier.weight(1f)) + } if (snapshot?.avatarFile != null) { Text( - "REMOVE PHOTO", + stringResource(R.string.account_remove_photo), style = TrackProType.label, color = TrackProTheme.colors.danger, modifier = Modifier @@ -204,9 +230,9 @@ fun EditProfileScreen(navController: NavController) { try { online.profile.removeAvatar() } catch (e: ApiException) { - error = e.message + error = MessageText.localize(e.message) } catch (e: NetworkException) { - error = "Removing your photo needs a connection. ${e.message}" + error = context.getString(R.string.account_remove_photo_needs_connection, MessageText.localize(e.message) ?: "") } finally { photoBusy = false } @@ -220,18 +246,20 @@ fun EditProfileScreen(navController: NavController) { } } } - Bezel() - SectionLabel("Details", modifier = Modifier.padding(start = 14.dp, top = 18.dp, bottom = 8.dp)) - Bezel() + SectionTitle(stringResource(R.string.account_details)) Column( - modifier = Modifier.fillMaxWidth().background(TrackProTheme.colors.field).padding(14.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = Spacing.gutter) + .paddockCard() + .padding(Spacing.lg), + verticalArrangement = Arrangement.spacedBy(Spacing.md) ) { OutlinedTextField( value = name, onValueChange = { name = it.take(100) }, - label = { Text("Name on leaderboards", color = TrackProTheme.colors.textMuted) }, + label = { Text(stringResource(R.string.account_name_on_boards), color = TrackProTheme.colors.textMuted) }, singleLine = true, modifier = Modifier.fillMaxWidth(), colors = fieldColors() @@ -239,7 +267,7 @@ fun EditProfileScreen(navController: NavController) { OutlinedTextField( value = country, onValueChange = { country = it.take(60) }, - label = { Text("Country", color = TrackProTheme.colors.textMuted) }, + label = { Text(stringResource(R.string.builder_country), color = TrackProTheme.colors.textMuted) }, singleLine = true, modifier = Modifier.fillMaxWidth(), colors = fieldColors() @@ -247,7 +275,7 @@ fun EditProfileScreen(navController: NavController) { OutlinedTextField( value = bio, onValueChange = { bio = it.take(500) }, - label = { Text("About you", color = TrackProTheme.colors.textMuted) }, + label = { Text(stringResource(R.string.account_about_you), color = TrackProTheme.colors.textMuted) }, supportingText = { Text("${bio.length} / 500", color = TrackProTheme.colors.textMuted) }, minLines = 3, modifier = Modifier.fillMaxWidth(), @@ -255,7 +283,7 @@ fun EditProfileScreen(navController: NavController) { ) error?.let { Text(it, style = TrackProType.body, color = TrackProTheme.colors.deltaBad) } PrimaryButton( - text = if (busy) "Saving…" else "Save", + text = if (busy) stringResource(R.string.account_saving) else stringResource(R.string.account_save), enabled = !busy && name.trim().length >= 2, haptic = Haptic.Confirm, onClick = { @@ -266,9 +294,9 @@ fun EditProfileScreen(navController: NavController) { online.profile.update(displayName = name.trim(), bio = bio.trim(), country = country.trim()) navController.popBackStack() } catch (e: ApiException) { - error = e.message + error = MessageText.localize(e.message) } catch (e: NetworkException) { - error = "Saving needs a connection. ${e.message}" + error = context.getString(R.string.account_save_needs_connection, MessageText.localize(e.message) ?: "") } finally { busy = false } @@ -277,8 +305,7 @@ fun EditProfileScreen(navController: NavController) { modifier = Modifier.fillMaxWidth() ) } - Bezel() - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(Spacing.xl)) } } } diff --git a/app/src/main/java/com/example/trackpro/screens/profile/ProfileScreen.kt b/app/src/main/java/com/example/trackpro/screens/profile/ProfileScreen.kt index 1b78ad4..4831807 100644 --- a/app/src/main/java/com/example/trackpro/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/profile/ProfileScreen.kt @@ -1,16 +1,18 @@ package com.example.trackpro.screens.profile +import com.example.trackpro.components.pluralResource +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -19,13 +21,29 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.filled.Logout +import androidx.compose.material.icons.filled.DeleteForever +import androidx.compose.material.icons.filled.Download +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.EmojiEvents +import androidx.compose.material.icons.filled.Leaderboard +import androidx.compose.material.icons.filled.Router +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Sync import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import com.example.trackpro.components.localizedCountry +import com.example.trackpro.online.MessageText import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -35,24 +53,30 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.navigation.NavController import com.example.trackpro.TrackProApp import com.example.trackpro.components.Bezel -import com.example.trackpro.components.DashAction import com.example.trackpro.components.Haptic +import com.example.trackpro.components.IconCircle import com.example.trackpro.components.Instrument +import com.example.trackpro.components.PaddockCard import com.example.trackpro.components.PhotoFrame -import com.example.trackpro.components.Readout +import com.example.trackpro.components.PrimaryButton import com.example.trackpro.components.ScreenScaffold -import com.example.trackpro.components.SectionLabel +import com.example.trackpro.components.SectionTitle import com.example.trackpro.components.ToggleChip import com.example.trackpro.components.initialsOf import com.example.trackpro.components.isScrolledUnderChrome +import com.example.trackpro.components.pressable import com.example.trackpro.components.pressableRow import com.example.trackpro.components.rememberHaptics import com.example.trackpro.dataClasses.VehicleInformationData @@ -63,11 +87,13 @@ import com.example.trackpro.online.ApiException import com.example.trackpro.online.LocalCareer import com.example.trackpro.online.NetworkException import com.example.trackpro.online.PersonalBest +import com.example.trackpro.online.ProfileStats import com.example.trackpro.online.SyncScheduler import com.example.trackpro.online.describe +import com.example.trackpro.theme.Spacing +import com.example.trackpro.theme.TrackProShapes import com.example.trackpro.theme.TrackProType -import com.example.trackpro.theme.atSize -import com.example.trackpro.theme.field +import com.example.trackpro.theme.accentSoft import com.example.trackpro.theme.marking import com.example.trackpro.theme.markingDim import com.example.trackpro.theme.panel @@ -77,10 +103,10 @@ import java.time.OffsetDateTime import java.util.Date /** - * The Profile tab: a driver's career sheet, then their account, then the app. + * The Profile tab: who you are as a driver, then your account, then the app. * - * The sheet is built from this phone's own records, so it is complete with no account and - * no network. Signing in lays the account on top — a name and face, and the leaderboard + * The career is built from this phone's own records, so it is complete with no account and + * no network. Signing in lays the account on top - a name and face, and the leaderboard * place next to each personal best, which only the server can know. */ @Composable @@ -113,7 +139,7 @@ fun ProfileScreen(navController: NavController) { val scroll = rememberScrollState() val scrolled by scroll.isScrolledUnderChrome() - ScreenScaffold(title = "Profile", contentScrolled = scrolled) { padding -> + ScreenScaffold(title = stringResource(R.string.tab_profile), contentScrolled = scrolled) { padding -> Column( modifier = Modifier .fillMaxSize() @@ -121,41 +147,31 @@ fun ProfileScreen(navController: NavController) { .verticalScroll(scroll) .padding(top = padding.calculateTopPadding()) ) { - DriverPlacard( - name = snapshot?.profile?.displayName ?: signedIn?.displayName, - country = snapshot?.profile?.country, - bio = snapshot?.profile?.bio, - memberSince = snapshot?.profile?.memberSince, - avatar = online.photos.file(snapshot?.avatarFile), - signedIn = signedIn != null, - onEdit = { navController.navigate("profile/edit") }, - ) - Bezel() - - if (signedIn == null) { - Box(Modifier.padding(12.dp)) { - DashAction( - label = "Sign in", - detail = "Back up your garage · see your leaderboard places", - onClick = { navController.navigate("account") }, - labelSize = 28.sp - ) - } - Bezel() - } else if (problem != null) { - Notice(problem!!) - Bezel() + Column(Modifier.padding(horizontal = Spacing.gutter), verticalArrangement = Arrangement.spacedBy(Spacing.md)) { + Spacer(Modifier.height(Spacing.xs)) + DriverCard( + name = snapshot?.profile?.displayName ?: signedIn?.displayName, + country = snapshot?.profile?.country, + bio = snapshot?.profile?.bio, + memberSince = snapshot?.profile?.memberSince, + avatar = online.photos.file(snapshot?.avatarFile), + signedIn = signedIn != null, + onEdit = { navController.navigate("profile/edit") }, + onSignIn = { navController.navigate("account") }, + ) + if (signedIn != null && problem != null) Notice(MessageText.localize(problem!!)!!) + CareerCard(career = career, server = snapshot?.stats?.takeIf { career.isEmpty }) } - CareerReadouts(career = career, server = snapshot?.stats?.takeIf { career.isEmpty }) - - MainCarPlacard( + SectionTitle(stringResource(R.string.profile_main_car)) + MainCarCard( vehicle = vehicles.firstOrNull { it.vehicleId == career.mainVehicleId } ?: vehicles.singleOrNull(), photo = { online.photos.file(it) }, onOpen = { navController.navigate("vehicle/$it") }, onAdd = { navController.navigate("createvehicle") }, ) + SectionTitle(stringResource(R.string.profile_personal_bests)) PersonalBests( career = career, serverBests = snapshot?.stats?.personalBests.orEmpty(), @@ -166,27 +182,28 @@ fun ProfileScreen(navController: NavController) { ) if (signedIn != null) { + SectionTitle(stringResource(R.string.profile_account)) AccountGroup( onEdit = { navController.navigate("profile/edit") }, onSignedOut = { scope.launch { online.profile.clear() } }, ) } - SectionLabel("App", modifier = Modifier.padding(start = 14.dp, top = 18.dp, bottom = 8.dp)) - Bezel() - LinkRow("Settings", "Units · theme · GPS source · server") { navController.navigate("settings") } - Bezel() - LinkRow("Rig connection", "ESP32 link and diagnostics") { navController.navigate("esptest") } - Bezel() - Spacer(Modifier.height(24.dp)) + SectionTitle(stringResource(R.string.profile_app)) + ListCard { + LinkRow(Icons.Default.Settings, stringResource(R.string.settings_title), stringResource(R.string.profile_settings_hint)) { navController.navigate("settings") } + RowDivider() + LinkRow(Icons.Default.Router, stringResource(R.string.profile_rig), stringResource(R.string.profile_rig_hint)) { navController.navigate("esptest") } + } + Spacer(Modifier.height(Spacing.xl)) } } } -// ── Driver placard ──────────────────────────────────────────── +// ── Driver ──────────────────────────────────────────────────── @Composable -private fun DriverPlacard( +private fun DriverCard( name: String?, country: String?, bio: String?, @@ -194,54 +211,53 @@ private fun DriverPlacard( avatar: java.io.File?, signedIn: Boolean, onEdit: () -> Unit, + onSignIn: () -> Unit, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.field) - .then(if (signedIn) Modifier.pressableRow(onClick = onEdit) else Modifier) - .padding(horizontal = 14.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - PhotoFrame( - file = avatar, - contentDescription = if (avatar != null) "Profile photo" else null, - initials = initialsOf(name ?: "Driver"), - modifier = Modifier.size(84.dp) - ) - Spacer(Modifier.width(14.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = (name ?: "Driver").uppercase(), - style = TrackProType.titleLarge.atSize(22.sp), - color = TrackProTheme.colors.marking, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - Spacer(Modifier.height(4.dp)) - val since = memberSince?.let { runCatching { OffsetDateTime.parse(it).year }.getOrNull() } - Text( - text = when { - !signedIn -> "On this phone" - else -> listOfNotNull(country, since?.let { "Since $it" }).joinToString(" · ").ifEmpty { "TrackBoard driver" } - }.uppercase(), - style = TrackProType.label, - color = TrackProTheme.colors.markingDim + PaddockCard(onClick = if (signedIn) onEdit else null, padding = Spacing.lg) { + Row(verticalAlignment = Alignment.CenterVertically) { + PhotoFrame( + file = avatar, + contentDescription = if (avatar != null) stringResource(R.string.profile_photo) else null, + initials = initialsOf(name ?: stringResource(R.string.drive_driver)), + shape = CircleShape, + modifier = Modifier.size(72.dp) ) - if (!bio.isNullOrBlank()) { - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.width(Spacing.lg)) + Column(modifier = Modifier.weight(1f)) { Text( - text = bio, - style = TrackProType.body, - color = TrackProTheme.colors.markingDim, - maxLines = 3, + text = name ?: stringResource(R.string.drive_driver), + style = TrackProType.titleLarge, + color = TrackProTheme.colors.marking, + maxLines = 2, overflow = TextOverflow.Ellipsis ) + val since = memberSince?.let { runCatching { OffsetDateTime.parse(it).year }.getOrNull() } + Text( + text = when { + !signedIn -> stringResource(R.string.profile_racing_on_phone) + else -> listOfNotNull(country?.let { localizedCountry(it) }, since?.let { stringResource(R.string.profile_driver_since, it) }).joinToString(" · ").ifEmpty { stringResource(R.string.profile_trackboard_driver) } + }, + style = TrackProType.label, + color = TrackProTheme.colors.markingDim + ) + } + if (signedIn) { + IconCircle(Icons.Default.Edit, size = 36.dp) } } - if (signedIn) { - Spacer(Modifier.width(8.dp)) - Text("EDIT", style = TrackProType.label, color = TrackProTheme.colors.accent) + if (!bio.isNullOrBlank()) { + Spacer(Modifier.height(Spacing.md)) + Text(text = bio, style = TrackProType.body, color = TrackProTheme.colors.markingDim, maxLines = 4, overflow = TextOverflow.Ellipsis) + } + if (!signedIn) { + Spacer(Modifier.height(Spacing.lg)) + Text( + stringResource(R.string.profile_sign_in_hint), + style = TrackProType.label, + color = TrackProTheme.colors.markingDim + ) + Spacer(Modifier.height(Spacing.md)) + PrimaryButton(text = stringResource(R.string.common_sign_in), onClick = onSignIn, modifier = Modifier.fillMaxWidth()) } } } @@ -249,20 +265,21 @@ private fun DriverPlacard( @Composable private fun Notice(text: String) { Text( - text = text.uppercase(), + text = text, style = TrackProType.label, - color = TrackProTheme.colors.deltaBad, + color = TrackProTheme.colors.marking, modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.field) - .padding(horizontal = 14.dp, vertical = 12.dp) + .clip(TrackProShapes.control) + .background(TrackProTheme.colors.deltaBad.copy(alpha = 0.12f)) + .padding(horizontal = Spacing.lg, vertical = Spacing.md) ) } // ── Career ──────────────────────────────────────────────────── @Composable -private fun CareerReadouts(career: LocalCareer, server: com.example.trackpro.online.ProfileStats?) { +private fun CareerCard(career: LocalCareer, server: ProfileStats?) { // A fresh phone signed into an existing account has no local history yet; until its // sessions are recorded here, the account's own totals are the honest numbers to show. val laps = server?.lapCount ?: career.lapCount @@ -272,95 +289,92 @@ private fun CareerReadouts(career: LocalCareer, server: com.example.trackpro.onl val first = career.firstSessionAt val dash = "—" - SectionLabel("Career", modifier = Modifier.padding(start = 14.dp, top = 18.dp, bottom = 8.dp)) - Bezel() - Column( - modifier = Modifier - .fillMaxWidth() - .background(TrackProTheme.colors.field) - .padding(horizontal = 14.dp, vertical = 14.dp) - ) { - Readout( - value = if (sessions == 0) dash else "$laps", - caption = if (first != null) "Laps timed since ${DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(first))}" - else "Laps timed", - valueColor = if (sessions == 0) TrackProTheme.colors.markingDim else TrackProTheme.colors.marking, - valueSize = 52.sp + PaddockCard { + Text( + if (first != null) stringResource(R.string.profile_laps_since, DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(first))) else stringResource(R.string.profile_laps_timed), + style = TrackProType.label, + color = TrackProTheme.colors.markingDim ) + Text( + if (sessions == 0) dash else "$laps", + style = TrackProType.displayNumeric, + color = if (sessions == 0) TrackProTheme.colors.markingDim else TrackProTheme.colors.marking + ) + Spacer(Modifier.height(Spacing.md)) + Row( + modifier = Modifier + .fillMaxWidth() + .clip(TrackProShapes.control) + .background(TrackProTheme.colors.bgElevated) + ) { + Instrument(stringResource(R.string.profile_sessions), if (sessions == 0) dash else "$sessions", Modifier.weight(1f), valueSize = TrackProType.titleMedium.fontSize) + Instrument(stringResource(R.string.garage_tracks), if (tracks == 0) dash else "$tracks", Modifier.weight(1f), valueSize = TrackProType.titleMedium.fontSize) + Instrument( + stringResource(R.string.profile_km_lapped), + km?.let { String.format(java.util.Locale.US, "%.1f", it) } ?: dash, + Modifier.weight(1f), + valueSize = TrackProType.titleMedium.fontSize + ) + } } - Bezel() - Row(modifier = Modifier.fillMaxWidth().height(IntrinsicRowHeight)) { - Instrument("Sessions", if (sessions == 0) dash else "$sessions", Modifier.weight(1f).fillMaxHeight()) - Bezel(vertical = true, modifier = Modifier.height(IntrinsicRowHeight)) - Instrument("Tracks", if (tracks == 0) dash else "$tracks", Modifier.weight(1f).fillMaxHeight()) - Bezel(vertical = true, modifier = Modifier.height(IntrinsicRowHeight)) - Instrument("Km lapped", km?.let { String.format(java.util.Locale.US, "%.1f", it) } ?: dash, Modifier.weight(1f).fillMaxHeight()) - } - Bezel() } -private val IntrinsicRowHeight = 58.dp - // ── Main car ────────────────────────────────────────────────── @Composable -private fun MainCarPlacard( +private fun MainCarCard( vehicle: VehicleInformationData?, photo: (String?) -> java.io.File?, onOpen: (Long) -> Unit, onAdd: () -> Unit, ) { - SectionLabel("Main car", modifier = Modifier.padding(start = 14.dp, top = 18.dp, bottom = 8.dp)) - Bezel() - if (vehicle == null) { - Box(Modifier.background(TrackProTheme.colors.panel).padding(12.dp)) { - DashAction(label = "Add your car", detail = "Sessions are timed against it", onClick = onAdd, compact = true) - } - Bezel() - return - } - Column( - modifier = Modifier - .fillMaxWidth() - .pressableRow(onClick = { onOpen(vehicle.vehicleId) }) - .background(TrackProTheme.colors.field) - ) { - val file = photo(vehicle.photoFile) - if (file != null) { + Column(Modifier.padding(horizontal = Spacing.gutter)) { + if (vehicle == null) { + PaddockCard(onClick = onAdd) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconCircle(Icons.Default.EmojiEvents, size = 44.dp) + Spacer(Modifier.width(Spacing.lg)) + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.drive_add_car), style = TrackProType.titleMedium, color = TrackProTheme.colors.marking) + Text(stringResource(R.string.profile_add_car_hint), style = TrackProType.label, color = TrackProTheme.colors.markingDim) + } + } + } + } else { + PaddockCard(onClick = { onOpen(vehicle.vehicleId) }, padding = Spacing.md) { PhotoFrame( - file = file, + file = photo(vehicle.photoFile), contentDescription = "${vehicle.manufacturer} ${vehicle.model}", modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f) ) - } - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text( - text = "${vehicle.manufacturer} ${vehicle.model}".uppercase(), - style = TrackProType.titleLarge.atSize(20.sp), - color = TrackProTheme.colors.marking, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(Modifier.height(3.dp)) - Text( - text = listOfNotNull( - vehicle.year.takeIf { it > 0 }?.toString(), - vehicle.horsepower.takeIf { it > 0 }?.let { "$it HP" }, - vehicle.drivetrain.takeIf { it.isNotBlank() }?.uppercase(), - vehicle.weight.takeIf { it > 0 }?.let { "${it.toInt()} KG" }, - ).joinToString(" · "), - style = TrackProType.label, - color = TrackProTheme.colors.markingDim - ) + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.xs, vertical = Spacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + Column(Modifier.weight(1f)) { + Text( + text = "${vehicle.manufacturer} ${vehicle.model}", + style = TrackProType.titleMedium, + color = TrackProTheme.colors.marking, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = listOfNotNull( + vehicle.year.takeIf { it > 0 }?.toString(), + vehicle.horsepower.takeIf { it > 0 }?.let { "$it hp" }, + vehicle.drivetrain.takeIf { it.isNotBlank() }, + vehicle.weight.takeIf { it > 0 }?.let { "${it.toInt()} kg" }, + ).joinToString(" · "), + style = TrackProType.label, + color = TrackProTheme.colors.markingDim + ) + } + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = TrackProTheme.colors.markingDim) } - Text(if (file == null) "ADD PHOTO ›" else "›", style = TrackProType.label, color = TrackProTheme.colors.accent) + } } } - Bezel() } // ── Personal bests ──────────────────────────────────────────── @@ -384,16 +398,15 @@ private fun PersonalBests( onOpenTrack: (Long) -> Unit, ) { val serverById = serverBests.associateBy { it.trackId } + val res = LocalContext.current.resources + val fromAccount = stringResource(R.string.profile_from_account) val rows = buildList { career.bests.forEach { best -> val server = remoteIds[best.trackId]?.let(serverById::get) add( BestRow( trackName = best.trackName, - detail = listOfNotNull( - "${best.lapCount} lap${if (best.lapCount == 1) "" else "s"}", - vehicleName(best.vehicleId), - ).joinToString(" · "), + detail = listOfNotNull(res.getQuantityString(R.plurals.count_laps, best.lapCount, best.lapCount), vehicleName(best.vehicleId)).joinToString(" · "), bestMs = best.bestLapMs, rank = server?.rank, fieldSize = server?.fieldSize, @@ -408,10 +421,10 @@ private fun PersonalBests( BestRow( trackName = best.trackName, detail = listOfNotNull( - "${best.lapCount} lap${if (best.lapCount == 1) "" else "s"}", + res.getQuantityString(R.plurals.count_laps, best.lapCount, best.lapCount), best.vehicle?.let { "${it.manufacturer} ${it.model}" }, - "from your account", - ).joinToString(" · "), + fromAccount, + ).joinToString(" · "), bestMs = best.bestLapMs.toLong(), rank = best.rank, fieldSize = best.fieldSize, @@ -421,93 +434,85 @@ private fun PersonalBests( } }.sortedWith(compareBy({ it.rank == null }, { it.rank }, { it.trackName.lowercase() })) - SectionLabel("Personal bests", modifier = Modifier.padding(start = 14.dp, top = 18.dp, bottom = 8.dp)) - Bezel() if (rows.isEmpty()) { - Text( - text = "NO TIMED LAPS YET · YOUR BEST ON EACH TRACK LANDS HERE", - style = TrackProType.label, - color = TrackProTheme.colors.markingDim, - modifier = Modifier.fillMaxWidth().background(TrackProTheme.colors.field).padding(14.dp) - ) - Bezel() + Column(Modifier.padding(horizontal = Spacing.gutter)) { + PaddockCard { + Row(verticalAlignment = Alignment.CenterVertically) { + IconCircle(Icons.Default.EmojiEvents, size = 44.dp) + Spacer(Modifier.width(Spacing.lg)) + Column { + Text(stringResource(R.string.profile_no_bests), style = TrackProType.titleMedium, color = TrackProTheme.colors.marking) + Text(stringResource(R.string.profile_no_bests_hint), style = TrackProType.label, color = TrackProTheme.colors.markingDim) + } + } + } + } return } - // Column heads, placard style, aligned with the values below them. - Row( - modifier = Modifier.fillMaxWidth().background(TrackProTheme.colors.panel).padding(horizontal = 14.dp, vertical = 6.dp) - ) { - Text("TRACK", style = TrackProType.label, color = TrackProTheme.colors.markingDim, modifier = Modifier.weight(1f)) - Text("BEST", style = TrackProType.label, color = TrackProTheme.colors.markingDim, modifier = Modifier.width(96.dp)) - Text("PLACE", style = TrackProType.label, color = TrackProTheme.colors.markingDim, modifier = Modifier.width(64.dp)) - } - Bezel() - rows.forEach { row -> - Row( - modifier = Modifier - .fillMaxWidth() - .then(row.localTrackId?.let { id -> Modifier.pressableRow(onClick = { onOpenTrack(id) }) } ?: Modifier) - .background(TrackProTheme.colors.field) - .heightIn(min = 56.dp) - .padding(horizontal = 14.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(Modifier.weight(1f)) { - Text( - row.trackName.uppercase(), - style = TrackProType.titleMedium, - color = TrackProTheme.colors.marking, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(Modifier.height(2.dp)) - Text( - row.detail.uppercase(), - style = TrackProType.label, - color = TrackProTheme.colors.markingDim, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Text( - row.bestMs.toLapTimeString(), - style = TrackProType.statValue.atSize(18.sp), - // P1 is the fastest anyone has gone there: the one place purple is earned here. - color = if (row.rank == 1) TrackProTheme.colors.accent else TrackProTheme.colors.marking, - modifier = Modifier.width(96.dp) - ) - Row(Modifier.width(64.dp), verticalAlignment = Alignment.Bottom) { - if (row.rank != null) { - Text( - "P${row.rank}", - style = TrackProType.statValue.atSize(18.sp), - color = if (row.rank == 1) TrackProTheme.colors.accent else TrackProTheme.colors.marking - ) - row.fieldSize?.let { - Text("/$it", style = TrackProType.label, color = TrackProTheme.colors.markingDim, modifier = Modifier.padding(bottom = 3.dp)) - } - } else { + ListCard { + rows.forEachIndexed { i, row -> + if (i > 0) RowDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .then(row.localTrackId?.let { id -> Modifier.pressableRow(onClick = { onOpenTrack(id) }) } ?: Modifier) + .heightIn(min = 64.dp) + .padding(horizontal = Spacing.lg, vertical = Spacing.md), + verticalAlignment = Alignment.CenterVertically + ) { + PlaceBadge(row.rank) + Spacer(Modifier.width(Spacing.md)) + Column(Modifier.weight(1f)) { + Text(row.trackName, style = TrackProType.titleMedium, color = TrackProTheme.colors.marking, maxLines = 1, overflow = TextOverflow.Ellipsis) Text( - "—", - style = TrackProType.statValue.atSize(18.sp), - color = TrackProTheme.colors.markingDim + listOfNotNull(row.detail, row.fieldSize?.let { pluralResource(R.plurals.profile_of_drivers, it) }).joinToString(" · "), + style = TrackProType.label, + color = TrackProTheme.colors.markingDim, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } + Spacer(Modifier.width(Spacing.sm)) + Text( + row.bestMs.toLapTimeString(), + style = TrackProType.statValue.copy(fontSize = TrackProType.titleMedium.fontSize), + color = if (row.rank == 1) TrackProTheme.colors.accent else TrackProTheme.colors.marking + ) } } - Bezel() } if (!signedIn) { Text( - "PLACES APPEAR WHEN YOU SIGN IN AND SHARE YOUR LAPS", + stringResource(R.string.profile_places_hint), style = TrackProType.label, color = TrackProTheme.colors.markingDim, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp) + modifier = Modifier.padding(horizontal = Spacing.gutter + Spacing.xs, vertical = Spacing.sm) ) } } +/** A leaderboard place as a round badge: orange for P1, raised for the rest, a dash for none. */ +@Composable +private fun PlaceBadge(rank: Int?) { + val p1 = rank == 1 + Text( + text = rank?.let { "P$it" } ?: "—", + style = TrackProType.label.copy(fontWeight = FontWeight.SemiBold), + color = when { + p1 -> TrackProTheme.colors.onAccent + rank != null -> TrackProTheme.colors.marking + else -> TrackProTheme.colors.markingDim + }, + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(if (p1) TrackProTheme.colors.accent else TrackProTheme.colors.bgElevated) + .padding(top = 11.dp), + textAlign = androidx.compose.ui.text.style.TextAlign.Center + ) +} + // ── Account ─────────────────────────────────────────────────── @Composable @@ -521,83 +526,83 @@ private fun AccountGroup(onEdit: () -> Unit, onSignedOut: () -> Unit) { var message by remember { mutableStateOf(null) } var confirmDelete by remember { mutableStateOf(false) } + val exportSaved = stringResource(R.string.profile_export_saved) val exporter = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri: Uri? -> if (uri != null) scope.launch { message = try { online.profile.export(uri) - "Export saved." + exportSaved } catch (e: ApiException) { - e.message + MessageText.localize(e.message) } catch (e: NetworkException) { - e.message + MessageText.localize(e.message) } } } if (confirmDelete) { - DeleteAccountDialog( - onDismiss = { confirmDelete = false }, - onDeleted = { confirmDelete = false }, - ) + DeleteAccountDialog(onDismiss = { confirmDelete = false }, onDeleted = { confirmDelete = false }) } - SectionLabel("Account", modifier = Modifier.padding(start = 14.dp, top = 18.dp, bottom = 8.dp)) - Bezel() - LinkRow("Edit profile", "Name · photo · country · bio", onClick = onEdit) - Bezel() - ActionRow( - label = "Leaderboards", - value = if (sharing) "Sharing your laps" else "Not sharing your laps", - action = if (sharing) "On" else "Off", - selected = sharing, - ) { - online.settings.setSharingEnabled(!sharing) - SyncScheduler.syncSoon(context) - } - Text( - // Says exactly what leaves the phone, so the choice is an informed one. - "Shared: your name, lap and sector times, session date, car make, model and year, and which " + - "GPS timed it. Only sessions on premade or published tracks. Stopping takes them back down.", - style = TrackProType.body.atSize(12.sp), - color = TrackProTheme.colors.markingDim, - modifier = Modifier.fillMaxWidth().background(TrackProTheme.colors.field).padding(start = 14.dp, end = 14.dp, bottom = 12.dp) - ) - Bezel() - ActionRow( - label = "Sync", - value = describe(lastSync), - valueColor = if (lastSync?.problem != null && lastSync?.offline != true) TrackProTheme.colors.deltaBad else null, - action = if (syncing) "Syncing…" else "Sync now", - selected = false, - ) { - if (!syncing) { + ListCard { + LinkRow(Icons.Default.Edit, stringResource(R.string.profile_edit), stringResource(R.string.profile_edit_hint), onClick = onEdit) + RowDivider() + ActionRow( + icon = Icons.Default.Leaderboard, + label = stringResource(R.string.profile_share), + value = if (sharing) stringResource(R.string.profile_sharing_on) else stringResource(R.string.profile_sharing_off), + action = if (sharing) stringResource(R.string.common_on) else stringResource(R.string.common_off), + selected = sharing, + ) { + online.settings.setSharingEnabled(!sharing) SyncScheduler.syncSoon(context) - scope.launch { online.profile.refresh() } } - } - Bezel() - LinkRow("Export my data", "Everything the server holds, as one JSON file") { - exporter.launch("trackboard-export.json") - } - Bezel() - LinkRow("Sign out", "Your records stay on this phone") { - scope.launch { - SyncScheduler.cancelAll(context) - online.auth.signOut() - onSignedOut() + Text( + // Says exactly what leaves the phone, so the choice is an informed one. + stringResource(R.string.profile_share_detail), + style = TrackProType.label, + color = TrackProTheme.colors.markingDim, + modifier = Modifier.padding(start = 72.dp, end = Spacing.lg, bottom = Spacing.md) + ) + RowDivider() + ActionRow( + icon = Icons.Default.Sync, + label = stringResource(R.string.profile_sync), + value = describe(lastSync), + valueColor = if (lastSync?.problem != null && lastSync?.offline != true) TrackProTheme.colors.deltaBad else null, + action = if (syncing) stringResource(R.string.profile_syncing) else stringResource(R.string.profile_sync_now), + selected = false, + ) { + if (!syncing) { + SyncScheduler.syncSoon(context) + scope.launch { online.profile.refresh() } + } + } + RowDivider() + LinkRow(Icons.Default.Download, stringResource(R.string.profile_export), stringResource(R.string.profile_export_hint)) { + exporter.launch("trackboard-export.json") } + RowDivider() + LinkRow(Icons.AutoMirrored.Filled.Logout, stringResource(R.string.profile_sign_out), stringResource(R.string.profile_sign_out_hint)) { + scope.launch { + SyncScheduler.cancelAll(context) + online.events.clear() + online.auth.signOut() + onSignedOut() + } + } + RowDivider() + LinkRow(Icons.Default.DeleteForever, stringResource(R.string.profile_delete_account), stringResource(R.string.profile_delete_account_hint), danger = true) { confirmDelete = true } } - Bezel() - LinkRow("Delete account", "Removes it from the server for good", danger = true) { confirmDelete = true } - Bezel() message?.let { - Text(it.uppercase(), style = TrackProType.label, color = TrackProTheme.colors.markingDim, modifier = Modifier.padding(14.dp)) + Text(it, style = TrackProType.label, color = TrackProTheme.colors.markingDim, modifier = Modifier.padding(horizontal = Spacing.gutter + Spacing.xs, vertical = Spacing.sm)) } } /** * Account deletion is irreversible, so it takes a typed word, not a second tap: a tap can be - * a slip on a bumpy paddock, typing DELETE cannot. + * a slip on a bumpy paddock, typing DELETE cannot. It also takes the password, which the + * server checks, so a phone left unlocked or a stolen sign-in cannot delete the account. */ @Composable private fun DeleteAccountDialog(onDismiss: () -> Unit, onDeleted: () -> Unit) { @@ -606,6 +611,8 @@ private fun DeleteAccountDialog(onDismiss: () -> Unit, onDeleted: () -> Unit) { val scope = rememberCoroutineScope() val haptics = rememberHaptics() var typed by remember { mutableStateOf("") } + val deleteWord = stringResource(R.string.profile_delete_word) + var password by remember { mutableStateOf("") } var busy by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } @@ -614,22 +621,32 @@ private fun DeleteAccountDialog(onDismiss: () -> Unit, onDeleted: () -> Unit) { containerColor = TrackProTheme.colors.bgCard, titleContentColor = TrackProTheme.colors.textPrimary, textContentColor = TrackProTheme.colors.textMuted, - title = { Text("Delete your TrackBoard account?") }, + title = { Text(stringResource(R.string.profile_delete_title)) }, text = { - Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { Text( - "Your profile, backed-up cars, photos and every lap you shared are removed from the " + - "server, and you leave every leaderboard. The cars, tracks and sessions on this " + - "phone are not touched." + stringResource(R.string.profile_delete_msg) ) OutlinedTextField( value = typed, onValueChange = { typed = it }, - label = { Text("Type DELETE to confirm", color = TrackProTheme.colors.textMuted) }, + label = { Text(stringResource(R.string.profile_type_to_confirm, deleteWord)) }, + singleLine = true, + shape = TrackProShapes.control, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = TrackProTheme.colors.danger, + unfocusedBorderColor = TrackProTheme.colors.sectorLine + ) + ) + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text(stringResource(R.string.profile_password)) }, singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + shape = TrackProShapes.control, colors = OutlinedTextFieldDefaults.colors( - focusedTextColor = TrackProTheme.colors.textPrimary, - unfocusedTextColor = TrackProTheme.colors.textPrimary, focusedBorderColor = TrackProTheme.colors.danger, unfocusedBorderColor = TrackProTheme.colors.sectorLine ) @@ -639,62 +656,74 @@ private fun DeleteAccountDialog(onDismiss: () -> Unit, onDeleted: () -> Unit) { }, confirmButton = { TextButton( - enabled = typed.trim() == "DELETE" && !busy, + enabled = typed.trim().equals(deleteWord, ignoreCase = true) && password.isNotEmpty() && !busy, onClick = { busy = true error = null haptics.perform(Haptic.Reject) scope.launch { try { - online.profile.deleteAccount() + online.profile.deleteAccount(password) + online.events.clear() onDeleted() } catch (e: ApiException) { - error = e.message + error = MessageText.localize(e.message) } catch (e: NetworkException) { - error = e.message + error = MessageText.localize(e.message) } finally { busy = false } } } ) { - Text(if (busy) "Deleting…" else "Delete account", color = TrackProTheme.colors.danger, style = TrackProType.titleMedium) + Text(if (busy) stringResource(R.string.profile_deleting) else stringResource(R.string.profile_delete_account), color = TrackProTheme.colors.danger, style = TrackProType.titleMedium) } }, dismissButton = { - TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel", color = TrackProTheme.colors.textMuted) } + TextButton(onClick = onDismiss, enabled = !busy) { Text(stringResource(R.string.common_cancel), color = TrackProTheme.colors.textMuted) } } ) } // ── Rows ────────────────────────────────────────────────────── +/** A card holding a list of rows, inside the page margin. */ @Composable -internal fun LinkRow(label: String, detail: String, danger: Boolean = false, onClick: () -> Unit) { +internal fun ListCard(content: @Composable ColumnScope.() -> Unit) { + Column(Modifier.padding(horizontal = Spacing.gutter)) { + PaddockCard(padding = 0.dp, content = content) + } +} + +@Composable +internal fun RowDivider() { + Bezel(modifier = Modifier.padding(start = 72.dp)) +} + +@Composable +internal fun LinkRow(icon: ImageVector, label: String, detail: String, danger: Boolean = false, onClick: () -> Unit) { + val tone = if (danger) TrackProTheme.colors.danger else TrackProTheme.colors.accent Row( modifier = Modifier .fillMaxWidth() .pressableRow(onClick = onClick) - .background(TrackProTheme.colors.field) - .heightIn(min = 56.dp) - .padding(horizontal = 14.dp, vertical = 10.dp), + .heightIn(min = 64.dp) + .padding(horizontal = Spacing.lg, vertical = Spacing.md), verticalAlignment = Alignment.CenterVertically ) { + IconCircle(icon, tint = tone, size = 40.dp) + Spacer(Modifier.width(Spacing.lg)) Column(Modifier.weight(1f)) { - Text( - label.uppercase(), - style = TrackProType.titleMedium, - color = if (danger) TrackProTheme.colors.danger else TrackProTheme.colors.marking - ) - Spacer(Modifier.height(2.dp)) - Text(detail, style = TrackProType.body.atSize(12.sp), color = TrackProTheme.colors.markingDim) + Text(label, style = TrackProType.titleMedium, color = if (danger) TrackProTheme.colors.danger else TrackProTheme.colors.marking) + Text(detail, style = TrackProType.label, color = TrackProTheme.colors.markingDim) } - Text("›", style = TrackProType.titleLarge, color = if (danger) TrackProTheme.colors.danger else TrackProTheme.colors.markingDim) + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, contentDescription = null, tint = TrackProTheme.colors.markingDim) } } @Composable private fun ActionRow( + icon: ImageVector, label: String, value: String, action: String, @@ -705,16 +734,17 @@ private fun ActionRow( Row( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.field) - .padding(horizontal = 14.dp, vertical = 10.dp), + .heightIn(min = 64.dp) + .padding(horizontal = Spacing.lg, vertical = Spacing.md), verticalAlignment = Alignment.CenterVertically ) { + IconCircle(icon, size = 40.dp) + Spacer(Modifier.width(Spacing.lg)) Column(Modifier.weight(1f)) { - Text(label.uppercase(), style = TrackProType.titleMedium, color = TrackProTheme.colors.marking) - Spacer(Modifier.height(2.dp)) - Text(value, style = TrackProType.body.atSize(12.sp), color = valueColor ?: TrackProTheme.colors.markingDim) + Text(label, style = TrackProType.titleMedium, color = TrackProTheme.colors.marking) + Text(value, style = TrackProType.label, color = valueColor ?: TrackProTheme.colors.markingDim) } - Spacer(Modifier.width(10.dp)) + Spacer(Modifier.width(Spacing.md)) ToggleChip(text = action, selected = selected, onClick = onAction) } } diff --git a/app/src/main/java/com/example/trackpro/screens/telemetricScreens/DragScreen.kt b/app/src/main/java/com/example/trackpro/screens/telemetricScreens/DragScreen.kt index 22486a1..98bb1e6 100644 --- a/app/src/main/java/com/example/trackpro/screens/telemetricScreens/DragScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/telemetricScreens/DragScreen.kt @@ -1,5 +1,8 @@ package com.example.trackpro.screens.telemetricScreens +import com.example.trackpro.components.pluralResource +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -24,6 +27,11 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import com.example.trackpro.components.paddockCard +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.draw.clip +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -149,12 +157,12 @@ fun DragRaceScreen( headline = currentMetrics.quarterMileTime?.let { formatTime(it) } ?: currentMetrics.standing.lastOrNull { it.seconds != null }?.seconds ?.let { formatTime(it) } ?: "\u2014", - headlineCaption = if (currentMetrics.quarterMileTime != null) "Quarter mile" - else "Best split", + headlineCaption = if (currentMetrics.quarterMileTime != null) stringResource(R.string.drag_quarter_mile_caption) + else stringResource(R.string.drag_best_split), rows = listOf( - "Runs" to "${currentMetrics.runCount}", - "Top speed" to "${UnitFormatter.formatSpeed(currentMetrics.maxSpeed, useMetric)} ${UnitFormatter.speedUnitLabel(useMetric)}", - "Distance" to UnitFormatter.formatDistance(currentMetrics.totalDistance.toDouble(), useMetric) + stringResource(R.string.hud_runs) to "${currentMetrics.runCount}", + stringResource(R.string.car_top_speed) to "${UnitFormatter.formatSpeed(currentMetrics.maxSpeed, useMetric)} ${UnitFormatter.speedUnitLabel(useMetric)}", + stringResource(R.string.drag_distance) to UnitFormatter.formatDistance(currentMetrics.totalDistance.toDouble(), useMetric) ), saveFailed = saveError, onKeep = { recorder.clearLastRun(); onBack() }, @@ -165,11 +173,10 @@ fun DragRaceScreen( if (confirmStop) { ConfirmDeleteDialog( - title = "Stop recording?", - message = "The run is written to the archive. You can void it on the next " + - "screen if it should not count.", - confirmLabel = "Stop", - dismissLabel = "Keep recording", + title = stringResource(R.string.drag_stop_title), + message = stringResource(R.string.drag_stop_msg), + confirmLabel = stringResource(R.string.drag_stop), + dismissLabel = stringResource(R.string.drag_keep_recording), onConfirm = { confirmStop = false; recorder.stop() }, onDismiss = { confirmStop = false } ) @@ -183,7 +190,7 @@ fun DragRaceScreen( // 1. TOP STATUS BAR AppTopBar( - title = "Drag Mode", + title = stringResource(R.string.mode_drag), // Guarded while live: Stop is a commit, and this arrow sits where a hand lands // adjusting a mount. onBack = { if (isSessionActive) confirmStop = true else onBack() }, @@ -198,7 +205,7 @@ fun DragRaceScreen( // comes back to a standstill, and this is what tells the driver it // did - the next launch is measured too. Text( - "RUN ${currentMetrics.runCount}", + stringResource(R.string.drag_run_n, currentMetrics.runCount), style = TrackProType.label, color = TrackProTheme.colors.markingDim ) @@ -206,15 +213,16 @@ fun DragRaceScreen( // The elapsed clock used to be here AND in the readout below. One is // enough, and the readout is where the eye already is. Text( - if (isConnected) "GPS LOCKED" else "NO SIGNAL", + if (isConnected) stringResource(R.string.drag_gps_locked) else stringResource(R.string.drive_no_signal), style = TrackProType.label, // A fault is never dimmer than health. color = if (isConnected) TrackProTheme.colors.deltaGood - else TrackProTheme.colors.deltaBad + else TrackProTheme.colors.danger ) Spacer(Modifier.width(Spacing.sm)) + val dataState = if (showData) stringResource(R.string.drag_shown) else stringResource(R.string.drag_hidden) Text( - "DATA", + stringResource(R.string.drag_data), style = TrackProType.label, color = if (showData) TrackProTheme.colors.marking else TrackProTheme.colors.markingDim, @@ -227,7 +235,7 @@ fun DragRaceScreen( role = Role.Switch ) .semantics { - stateDescription = if (showData) "Shown" else "Hidden" + stateDescription = dataState } .heightIn(min = 48.dp) .wrapContentHeight(Alignment.CenterVertically) @@ -253,7 +261,7 @@ fun DragRaceScreen( AppCard( modifier = Modifier .fillMaxWidth() - .padding(Spacing.md) + .padding(horizontal = Spacing.gutter, vertical = Spacing.sm) .pressable(onClick = { showVehicleDropdown = true }) ) { Row( @@ -262,11 +270,11 @@ fun DragRaceScreen( verticalAlignment = Alignment.CenterVertically ) { Column(Modifier.weight(1f)) { - Text("Vehicle", style = TrackProType.label, color = TrackProTheme.colors.textMuted) + Text(stringResource(R.string.car_title), style = TrackProType.label, color = TrackProTheme.colors.textMuted) Spacer(Modifier.height(4.dp)) Text( selectedVehicle?.let { "${it.manufacturer} ${it.model}" } - ?: "Select Vehicle", + ?: stringResource(R.string.drag_select_car), style = TrackProType.titleMedium, color = selectedVehicle?.let { TrackProTheme.colors.textPrimary } ?: TrackProTheme.colors.textMuted.copy(alpha = 0.5f), @@ -322,12 +330,9 @@ fun DragRaceScreen( Box( Modifier .fillMaxWidth() - .padding(horizontal = Spacing.md, vertical = Spacing.sm) - .background( - TrackProTheme.colors.bgCard.copy(alpha = 0.5f), - TrackProShapes.control - ) - .padding(Spacing.sm) + .padding(horizontal = Spacing.gutter, vertical = Spacing.sm) + .background(TrackProTheme.colors.bgCard, TrackProShapes.control) + .padding(horizontal = Spacing.lg, vertical = Spacing.md) ) { selectedVehicle?.let { Text( @@ -349,8 +354,8 @@ fun DragRaceScreen( currentMetrics.standing.mapNotNull { split -> split.seconds?.let { split.label to it } } + listOfNotNull( - currentMetrics.quarterMileTime?.let { "1/4 MILE" to it }, - currentMetrics.halfMileTime?.let { "1/2 MILE" to it } + currentMetrics.quarterMileTime?.let { stringResource(R.string.drag_quarter_upper) to it }, + currentMetrics.halfMileTime?.let { stringResource(R.string.drag_half_upper) to it } ) val latestSplit: Pair? = reachedSplits.lastOrNull() ?.let { (label, seconds) -> label to formatTime(seconds) } @@ -365,25 +370,31 @@ fun DragRaceScreen( Column( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.field) - .padding(horizontal = 14.dp, vertical = 14.dp) + .padding(horizontal = Spacing.gutter) + .paddockCard() + .padding(Spacing.lg) ) { Readout( value = latestSplit?.second ?: (gpsData?.speed?.let { UnitFormatter.formatSpeed(it, useMetric) } ?: "0"), - caption = latestSplit?.let { "${it.first} \u00b7 SEC" } - ?: "Live speed \u00b7 ${UnitFormatter.speedUnitLabel(useMetric)}", + caption = latestSplit?.let { stringResource(R.string.drag_split_seconds, it.first) } + ?: stringResource(R.string.drag_live_speed, UnitFormatter.speedUnitLabel(useMetric)), valueColor = if (latestSplit != null) TrackProTheme.colors.accent else TrackProTheme.colors.marking, valueSize = 72.sp, trailing = { Column(horizontalAlignment = Alignment.End) { + val recTone = if (isSessionActive) TrackProTheme.colors.danger else TrackProTheme.colors.markingDim Text( - text = if (isSessionActive) "REC" else "IDLE", - style = TrackProType.label, - color = if (isSessionActive) TrackProTheme.colors.danger - else TrackProTheme.colors.markingDim + text = if (isSessionActive) stringResource(R.string.drag_recording) else stringResource(R.string.drag_idle), + style = TrackProType.label.copy(fontWeight = FontWeight.SemiBold), + color = recTone, + modifier = Modifier + .clip(TrackProShapes.pill) + .background(recTone.copy(alpha = 0.14f)) + .padding(horizontal = Spacing.sm, vertical = 2.dp) ) + Spacer(Modifier.height(Spacing.xs)) Text( text = elapsedTime, style = TrackProType.statValue.atSize(18.sp), @@ -392,7 +403,7 @@ fun DragRaceScreen( } } ) - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(Spacing.md)) // How far through the run's split ladder you are. Discrete by nature, which // is what a segment bar is for, and it pairs with the split held above it: // big number is the last one reached, bar is where that sits in the run. @@ -422,7 +433,7 @@ fun DragRaceScreen( } ) - Bezel() + Spacer(Modifier.height(Spacing.md)) // Everything below is reference, not run material: hidden while recording // unless the driver asks for it with DATA. At the strip the panel is the split @@ -441,16 +452,21 @@ fun DragRaceScreen( split.label to split.seconds?.let { formatTime(it) } } val splitCells = speedCells + listOf( - "1/4 mile" to currentMetrics.quarterMileTime?.let { formatTime(it) }, + stringResource(R.string.drag_quarter_lower) to currentMetrics.quarterMileTime?.let { formatTime(it) }, // Trap speed is the one cell here holding a speed rather than a time, so it // carries its unit in the placard - every other cell is seconds. - "1/4 trap ${UnitFormatter.speedUnitLabel(useMetric)}" to + stringResource(R.string.drag_quarter_trap, UnitFormatter.speedUnitLabel(useMetric)) to currentMetrics.quarterMileSpeed?.let { UnitFormatter.formatSpeed(it, useMetric) }, - "1/2 mile" to currentMetrics.halfMileTime?.let { formatTime(it) } + stringResource(R.string.drag_half_lower) to currentMetrics.halfMileTime?.let { formatTime(it) } ) + Column( + Modifier + .padding(horizontal = Spacing.gutter) + .paddockCard() + ) { splitCells.chunked(3).forEachIndexed { rowIndex, row -> Row(modifier = Modifier.fillMaxWidth()) { row.forEachIndexed { i, (label, value) -> @@ -467,21 +483,21 @@ fun DragRaceScreen( } } } - if (rowIndex < (splitCells.size - 1) / 3) Bezel() + if (rowIndex < (splitCells.size - 1) / 3) Bezel(Modifier.padding(horizontal = Spacing.lg)) + } } - Bezel() - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(Spacing.md)) // 5. SPEED CHART (LARGE) AppCard( modifier = Modifier .fillMaxWidth() .height(300.dp) - .padding(horizontal = Spacing.md) + .padding(horizontal = Spacing.gutter) ) { Column { - SectionLabel("Speed Profile") + SectionLabel(stringResource(R.string.drag_speed_profile)) Spacer(Modifier.height(8.dp)) AndroidView( @@ -502,6 +518,10 @@ fun DragRaceScreen( setPinchZoom(true) setDrawBorders(false) setBackgroundColor(android.graphics.Color.TRANSPARENT) + // The library's default is a yellow sentence that reads as + // a warning. An empty chart before a run is not one. + setNoDataText(ctx.getString(R.string.drag_chart_empty)) + setNoDataTextColor(DataVizColors.chartAxisText.toColorInt()) } }, modifier = Modifier.fillMaxSize(), @@ -542,7 +562,7 @@ fun DragRaceScreen( Modifier .fillMaxWidth() .background(TrackProTheme.colors.bgDeep) - .padding(Spacing.md), + .padding(horizontal = Spacing.gutter, vertical = Spacing.md), horizontalArrangement = Arrangement.spacedBy(Spacing.md) ) { DashAction( @@ -550,14 +570,14 @@ fun DragRaceScreen( // doing: it reloads asynchronously after the screen is recreated, and // resolving the name first left Stop disabled mid-run until it arrived. label = when { - isSessionActive -> "Stop" - selectedVehicleId == null -> "Select a vehicle" - else -> "Start drag" + isSessionActive -> stringResource(R.string.drag_stop) + selectedVehicleId == null -> stringResource(R.string.drag_select_vehicle) + else -> stringResource(R.string.drag_start) }, detail = when { - isSessionActive -> "Recording \u00b7 ${currentMetrics.runCount} run(s)" - selectedVehicleId == null -> "Every run is recorded against a car" - else -> "Arm the timer and launch" + isSessionActive -> pluralResource(R.plurals.drag_recording_runs, currentMetrics.runCount) + selectedVehicleId == null -> stringResource(R.string.drag_every_run_car) + else -> stringResource(R.string.drag_arm) }, onClick = { // Both sides open and close the session, write the trace and manage the @@ -576,8 +596,9 @@ fun DragRaceScreen( haptic = Haptic.Confirm, // Recording takes the danger accent: it is the one state that must be // unmistakable at a glance from outside the car. - accent = if (isSessionActive) TrackProTheme.colors.danger - else TrackProTheme.colors.accent, + accent = if (isSessionActive) TrackProTheme.colors.danger else null, + primary = true, + icon = if (isSessionActive) Icons.Default.Stop else Icons.Default.PlayArrow, modifier = Modifier.weight(1f) ) } @@ -605,7 +626,7 @@ fun DragMetricCard( verticalAlignment = Alignment.CenterVertically ) { Text( - metric.label.uppercase(), + metric.label, style = TrackProType.label, // Unreached splits stay quieter than reached ones, but both clear AA; // textFaint measured 3.35:1 and this is a 10sp placard. @@ -665,14 +686,12 @@ private fun AdaptiveSplit( .height(IntrinsicSize.Min) ) { Box(Modifier.weight(0.58f)) { primary() } - Bezel(vertical = true, modifier = Modifier.fillMaxHeight()) - Box(Modifier.weight(0.42f)) { secondary() } + Box(Modifier.weight(0.42f).padding(end = Spacing.gutter)) { secondary() } } } else { - Column(Modifier.fillMaxWidth()) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(Spacing.md)) { primary() - Bezel() - secondary() + Box(Modifier.padding(horizontal = Spacing.gutter)) { secondary() } } } } @@ -688,12 +707,12 @@ private fun DragLiveInstruments( distanceText: String ) { val cells: List> = listOf( - Triple("Speed \u00b7 $speedUnit", speedText, speedTrend), - Triple("Max", maxText, null), - Triple("Distance", distanceText, null) + Triple(stringResource(R.string.drag_speed_unit, speedUnit), speedText, speedTrend), + Triple(stringResource(R.string.drag_max), maxText, null), + Triple(stringResource(R.string.drag_distance), distanceText, null) ) if (landscape) { - Column(Modifier.fillMaxWidth()) { + Column(Modifier.fillMaxWidth().paddockCard()) { cells.forEachIndexed { i, (label, value, trend) -> Instrument( label = label, @@ -706,7 +725,7 @@ private fun DragLiveInstruments( } } } else { - Row(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth().paddockCard()) { cells.forEachIndexed { i, (label, value, trend) -> Instrument( label = label, diff --git a/app/src/main/java/com/example/trackpro/screens/telemetricScreens/TimeAttackScreen.kt b/app/src/main/java/com/example/trackpro/screens/telemetricScreens/TimeAttackScreen.kt index a0a7e15..49b3db3 100644 --- a/app/src/main/java/com/example/trackpro/screens/telemetricScreens/TimeAttackScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/telemetricScreens/TimeAttackScreen.kt @@ -1,5 +1,7 @@ package com.example.trackpro.screens.telemetricScreens +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import android.content.Context import android.content.res.Configuration import android.os.SystemClock @@ -22,6 +24,11 @@ import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.runtime.Composable +import com.example.trackpro.managerClasses.utilities.toLapTimeMillisOrNull +import com.example.trackpro.components.PersonalBestMoment +import com.example.trackpro.components.paddockCard +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.draw.clip import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -149,10 +156,11 @@ fun TimeAttackScreenView( val effectiveDelta = liveDelta ?: delta val isLiveDelta = liveDelta != null val isCircuit = timingMode is TimingMode.Circuit + val loadFailedText = stringResource(R.string.hud_load_failed) val deltaCaption = if (isCircuit) { captionForDelta(isLiveDelta, activeReference, preferredReference) } else { - if (isLiveDelta) "Delta to best - live" else "Delta to best" + if (isLiveDelta) stringResource(R.string.hud_delta_best_live) else stringResource(R.string.hud_delta_best) } val linesToShow by remember(timingMode, startLine, finishLine) { @@ -194,7 +202,7 @@ fun TimeAttackScreenView( vm.ensureSession(trackId, vehicleId) } catch (e: Exception) { Log.e("TimeAttackScreen", "Initialization error: ${e.message}", e) - initError = e.message ?: "Could not load the track or start the session" + initError = e.message ?: loadFailedText } } @@ -224,13 +232,18 @@ fun TimeAttackScreenView( var showSummary by remember { mutableStateOf(false) } if (showSummary) { + val priorBest by vm.priorTrackBestMs.collectAsState() + val sessionBest = bestTime.toLapTimeMillisOrNull() SessionSummary( + // Only a real improvement, or the first time here, is a peak worth marking. + personalBest = sessionBest?.takeIf { priorBest == null || it < priorBest!! } + ?.let { PersonalBestMoment(newMs = it, previousMs = priorBest) }, headline = bestTime, - headlineCaption = "Best lap", + headlineCaption = stringResource(R.string.session_best_lap), rows = listOf( - (if (timingMode is TimingMode.Circuit) "Laps" else "Runs") to "$eventCount", - "Last" to lastTime, - "Sectors logged" to "${lapSplits.size}" + (if (timingMode is TimingMode.Circuit) stringResource(R.string.session_laps) else stringResource(R.string.hud_runs)) to "$eventCount", + stringResource(R.string.hud_last) to lastTime, + stringResource(R.string.hud_sectors_logged) to "${lapSplits.size}" ), onKeep = { vm.keepAndEnd(); onBack() }, onVoid = { vm.voidAndEnd(); onBack() } @@ -240,11 +253,10 @@ fun TimeAttackScreenView( if (confirmEnd) { ConfirmDeleteDialog( - title = "End session?", - message = "Timing stops and the session is written to the archive. You can " + - "void it on the next screen if it should not count.", - confirmLabel = "End session", - dismissLabel = "Keep driving", + title = stringResource(R.string.hud_end_title), + message = stringResource(R.string.hud_end_msg), + confirmLabel = stringResource(R.string.hud_end_confirm), + dismissLabel = stringResource(R.string.hud_keep_driving), onConfirm = { confirmEnd = false; showSummary = true }, onDismiss = { confirmEnd = false } ) @@ -258,14 +270,14 @@ fun TimeAttackScreenView( .fillMaxSize() .background(TrackProTheme.colors.panel) ) { - AppTopBar(title = "Session failed", onBack = onBack, accent = TrackProTheme.colors.danger) + AppTopBar(title = stringResource(R.string.hud_session_failed), onBack = onBack, accent = TrackProTheme.colors.danger) Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(horizontal = 24.dp) ) { Text( - "COULD NOT START TIMING", + stringResource(R.string.hud_could_not_start), style = TrackProType.titleMedium, color = TrackProTheme.colors.danger ) @@ -278,7 +290,7 @@ fun TimeAttackScreenView( ) Spacer(Modifier.height(20.dp)) DashAction( - label = "Back to panel", + label = stringResource(R.string.hud_back_to_panel), onClick = onBack, compact = true, modifier = Modifier.width(200.dp) @@ -362,7 +374,7 @@ fun TimeAttackPortraitLayout( isConnected: Boolean, linesToShow : List, lapSplits: List = emptyList(), - deltaCaption: String = "Delta to best", + deltaCaption: String = stringResource(R.string.hud_delta_best), showReferenceSwitch: Boolean = false, preferredReference: DeltaReference = DeltaReference.SESSION_BEST, onReferenceChange: (DeltaReference) -> Unit = {}, @@ -372,8 +384,8 @@ fun TimeAttackPortraitLayout( onBack: () -> Unit ) { val deltaColor = if (delta <= 0) TrackProTheme.colors.deltaGood else TrackProTheme.colors.deltaBad - val eventName = if (timingMode is TimingMode.Circuit) "LAP" else "RUN" - val modeLabel = if (timingMode is TimingMode.Circuit) "CIRCUIT" else "SPRINT" + val eventName = if (timingMode is TimingMode.Circuit) stringResource(R.string.hud_lap) else stringResource(R.string.hud_run) + val modeLabel = if (timingMode is TimingMode.Circuit) stringResource(R.string.track_type_circuit) else stringResource(R.string.track_type_sprint) val modeColor = TrackProTheme.colors.accent // Gaining pushes the bar forward, so a lower-is-better delta is negated before // it reaches the bar. Two seconds fills it; past that the exact figure has @@ -400,8 +412,9 @@ fun TimeAttackPortraitLayout( Column( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.field) - .padding(horizontal = 14.dp, vertical = 14.dp) + .padding(horizontal = Spacing.gutter) + .paddockCard() + .padding(Spacing.lg) ) { Readout( value = String.format(Locale.US, "%+.3f", delta), @@ -424,12 +437,12 @@ fun TimeAttackPortraitLayout( } } ) - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(Spacing.md)) SegmentBar( signedFraction = deltaFraction, activeColor = deltaColor, segments = 25, - height = if (mapVisible) 18.dp else 26.dp + height = if (mapVisible) 16.dp else 24.dp ) if (showReferenceSwitch) { Spacer(Modifier.height(8.dp)) @@ -437,19 +450,19 @@ fun TimeAttackPortraitLayout( } } - Bezel() + Spacer(Modifier.height(Spacing.md)) // -- Clocks --------------------------------------- - Row(modifier = Modifier.fillMaxWidth()) { + Row(modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.gutter).paddockCard()) { Instrument( - label = "Current $eventName", + label = if (timingMode is TimingMode.Circuit) stringResource(R.string.hud_current_lap) else stringResource(R.string.hud_current_run), value = currentTime, valueSize = 24.sp, modifier = Modifier.weight(1f) ) Bezel(vertical = true, modifier = Modifier.height(62.dp)) Instrument( - label = "Best", + label = stringResource(R.string.lap_badge_best), value = bestTime, valueColor = TrackProTheme.colors.accent, valueSize = 24.sp, @@ -457,18 +470,18 @@ fun TimeAttackPortraitLayout( ) Bezel(vertical = true, modifier = Modifier.height(62.dp)) Instrument( - label = "Last", + label = stringResource(R.string.hud_last), value = lastTime, valueSize = 24.sp, modifier = Modifier.weight(1f) ) } - Bezel() + Spacer(Modifier.height(Spacing.md)) if (lapSplits.isNotEmpty()) { SectorSplitsRow(splits = lapSplits) - Bezel() + Spacer(Modifier.height(Spacing.md)) } if (mapVisible) { @@ -476,7 +489,9 @@ fun TimeAttackPortraitLayout( modifier = Modifier .fillMaxWidth() .weight(1f) - .background(TrackProTheme.colors.panel) + .padding(start = Spacing.gutter, end = Spacing.gutter, bottom = Spacing.gutter) + .clip(TrackProShapes.card) + .background(TrackProTheme.colors.bgCard) ) { if (gpsPoints.isNotEmpty()) { MapLibreTrackView( @@ -492,7 +507,7 @@ fun TimeAttackPortraitLayout( // geometry* is empty, which has nothing to do with the receiver. // A track saved without coordinates used to claim the GPS was // missing while it was locked and reporting fine. - "NO TRACK GEOMETRY", + stringResource(R.string.hud_no_geometry), style = TrackProType.label, color = TrackProTheme.colors.markingDim ) @@ -503,15 +518,16 @@ fun TimeAttackPortraitLayout( RecentLapsPanel( laps = completedLaps, eventName = eventName, + isCircuit = timingMode is TimingMode.Circuit, modifier = Modifier .fillMaxWidth() .weight(1f) + .padding(horizontal = Spacing.gutter) ) - Bezel() - Row(modifier = Modifier.fillMaxWidth()) { + Spacer(Modifier.height(Spacing.md)) + Row(modifier = Modifier.fillMaxWidth().padding(start = Spacing.gutter, end = Spacing.gutter, bottom = Spacing.gutter).paddockCard()) { StintTimerCell(stintStart = stintStart, modifier = Modifier.weight(1f)) } - Bezel() } } } @@ -535,7 +551,7 @@ fun TimeAttackLandscapeLayout( isConnected: Boolean, linesToShow: List, lapSplits: List = emptyList(), - deltaCaption: String = "Delta to best", + deltaCaption: String = stringResource(R.string.hud_delta_best), showReferenceSwitch: Boolean = false, preferredReference: DeltaReference = DeltaReference.SESSION_BEST, onReferenceChange: (DeltaReference) -> Unit = {}, @@ -545,8 +561,8 @@ fun TimeAttackLandscapeLayout( onBack: () -> Unit ) { val deltaColor = if (delta <= 0) TrackProTheme.colors.deltaGood else TrackProTheme.colors.deltaBad - val eventName = if (timingMode is TimingMode.Circuit) "LAP" else "RUN" - val modeLabel = if (timingMode is TimingMode.Circuit) "CIRCUIT" else "SPRINT" + val eventName = if (timingMode is TimingMode.Circuit) stringResource(R.string.hud_lap) else stringResource(R.string.hud_run) + val modeLabel = if (timingMode is TimingMode.Circuit) stringResource(R.string.track_type_circuit) else stringResource(R.string.track_type_sprint) val modeColor = TrackProTheme.colors.accent // Gaining pushes the bar forward, so a lower-is-better delta is negated before // it reaches the bar. Two seconds fills it; past that the exact figure has @@ -579,8 +595,9 @@ fun TimeAttackLandscapeLayout( Column( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.field) - .padding(horizontal = 14.dp, vertical = 12.dp) + .padding(horizontal = Spacing.gutter) + .paddockCard() + .padding(Spacing.md) ) { Readout( value = String.format(Locale.US, "%+.3f", delta), @@ -616,28 +633,29 @@ fun TimeAttackLandscapeLayout( } } - Bezel() + Spacer(Modifier.height(Spacing.sm)) + Column(Modifier.padding(horizontal = Spacing.gutter).paddockCard()) { Row(modifier = Modifier.fillMaxWidth()) { Instrument( - label = "Current $eventName", + label = if (timingMode is TimingMode.Circuit) stringResource(R.string.hud_current_lap) else stringResource(R.string.hud_current_run), value = currentTime, valueSize = 22.sp, modifier = Modifier.weight(1f) ) Bezel(vertical = true, modifier = Modifier.height(58.dp)) Instrument( - label = "Best", + label = stringResource(R.string.lap_badge_best), value = bestTime, valueColor = TrackProTheme.colors.accent, valueSize = 22.sp, modifier = Modifier.weight(1f) ) } - Bezel() + Bezel(Modifier.padding(horizontal = Spacing.lg)) Row(modifier = Modifier.fillMaxWidth()) { Instrument( - label = "Last", + label = stringResource(R.string.hud_last), value = lastTime, valueSize = 22.sp, modifier = Modifier.weight(1f) @@ -645,25 +663,27 @@ fun TimeAttackLandscapeLayout( Bezel(vertical = true, modifier = Modifier.height(58.dp)) StintTimerCell(stintStart = stintStart, modifier = Modifier.weight(1f)) } - Bezel() + } + Spacer(Modifier.height(Spacing.sm)) if (lapSplits.isNotEmpty()) { SectorSplitsRow(splits = lapSplits) } } - Bezel(vertical = true) - Box( modifier = Modifier .weight(0.54f) .fillMaxSize() - .background(TrackProTheme.colors.panel) + .padding(top = Spacing.md, end = Spacing.gutter, bottom = Spacing.md) + .clip(TrackProShapes.card) + .background(TrackProTheme.colors.bgCard) ) { when { !mapVisible -> RecentLapsPanel( laps = completedLaps, eventName = eventName, + isCircuit = timingMode is TimingMode.Circuit, modifier = Modifier.fillMaxSize() ) gpsPoints.isNotEmpty() -> MapLibreTrackView( @@ -674,7 +694,7 @@ fun TimeAttackLandscapeLayout( ) else -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Text( - "NO TRACK GEOMETRY", + stringResource(R.string.hud_no_geometry), style = TrackProType.label, color = TrackProTheme.colors.markingDim ) @@ -703,9 +723,14 @@ private fun HudTrailing( ) { val haptics = rememberHaptics() Row(verticalAlignment = Alignment.CenterVertically) { + val tone = if (isConnected) TrackProTheme.colors.deltaGood else TrackProTheme.colors.danger Text( - text = if (isConnected) "LIVE" else "NO SIGNAL", - style = TrackProType.label, + text = if (isConnected) stringResource(R.string.hud_live) else stringResource(R.string.drive_no_signal), + style = TrackProType.label.copy(fontWeight = FontWeight.SemiBold), + modifier = Modifier + .clip(TrackProShapes.pill) + .background(tone.copy(alpha = 0.14f)) + .padding(horizontal = Spacing.sm, vertical = 4.dp), // Fault Is Never Dimmer. NO SIGNAL invalidates every number on this panel, so // it cannot be the faintest thing on it - it was textFaint at 3.35:1 against // LIVE at 9.82:1, three times less visible than the healthy state, in sunlight. @@ -714,7 +739,7 @@ private fun HudTrailing( ) Spacer(Modifier.width(Spacing.md)) Text( - text = "MAP", + text = stringResource(R.string.drag_map), style = TrackProType.label, color = if (mapVisible) TrackProTheme.colors.marking else TrackProTheme.colors.markingDim @@ -747,21 +772,22 @@ private fun HudTrailing( * session best stands in. A driver reading "-0.4" needs to know which of the two it is * against, or the number means nothing. */ +@Composable private fun captionForDelta( isLive: Boolean, active: DeltaReference?, preferred: DeltaReference ): String = when { active == DeltaReference.TRACK_BEST -> - if (isLive) "Delta to track best - live" else "Delta to track best" + if (isLive) stringResource(R.string.hud_delta_track_live) else stringResource(R.string.hud_delta_track) active == DeltaReference.SESSION_BEST && preferred == DeltaReference.TRACK_BEST -> - "Delta to session best - no track best yet" + stringResource(R.string.hud_delta_session_no_track) active == DeltaReference.SESSION_BEST -> - if (isLive) "Delta to session best - live" else "Delta to session best" + if (isLive) stringResource(R.string.hud_delta_session_live) else stringResource(R.string.hud_delta_session) // Nothing to measure against yet: before the first crossing, or lap 1 of a session // measured against the session best. - preferred == DeltaReference.TRACK_BEST -> "Delta to track best" - else -> "Delta to session best" + preferred == DeltaReference.TRACK_BEST -> stringResource(R.string.hud_delta_track) + else -> stringResource(R.string.hud_delta_session) } /** @@ -790,14 +816,14 @@ private fun DeltaReferenceSwitch( horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = "COMPARE TO", + text = stringResource(R.string.hud_compare_to), style = TrackProType.label, color = TrackProTheme.colors.markingDim ) Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { listOf( - DeltaReference.SESSION_BEST to "SESSION BEST", - DeltaReference.TRACK_BEST to "TRACK BEST" + DeltaReference.SESSION_BEST to stringResource(R.string.hud_session_best), + DeltaReference.TRACK_BEST to stringResource(R.string.hud_track_best) ).forEach { (reference, label) -> val isSelected = reference == preferred // Selection is a lit aperture, not a hue swap. Colour alone failed twice @@ -806,15 +832,10 @@ private fun DeltaReferenceSwitch( // number on the screen is measured against. Box( modifier = Modifier - .background( - if (isSelected) TrackProTheme.colors.accent else TrackProTheme.colors.panel - ) - .border( - 1.dp, - if (isSelected) TrackProTheme.colors.accent else TrackProTheme.colors.bezel - ) + .clip(TrackProShapes.pill) + .background(if (isSelected) TrackProTheme.colors.accent else TrackProTheme.colors.bgElevated) .semantics { selected = isSelected } - .padding(horizontal = 8.dp, vertical = 4.dp) + .padding(horizontal = Spacing.md, vertical = 6.dp) ) { Text( text = label, @@ -841,7 +862,7 @@ private fun StintTimerCell(stintStart: Long, modifier: Modifier = Modifier) { stintTime = String.format(Locale.US, "%02d:%02d:%02d", h, m, s) } } - Instrument(label = "Stint", value = stintTime, valueSize = 22.sp, modifier = modifier) + Instrument(label = stringResource(R.string.hud_stint), value = stintTime, valueSize = 22.sp, modifier = modifier) } @Composable @@ -854,9 +875,10 @@ private fun SectorSplitsRow(splits: List) { Row( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.field) - .padding(horizontal = 14.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(3.dp) + .padding(horizontal = Spacing.gutter) + .paddockCard() + .padding(horizontal = Spacing.lg, vertical = Spacing.md), + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { splits.forEach { split -> val gained = (split.deltaMs ?: 0L) <= 0L @@ -874,6 +896,7 @@ private fun SectorSplitsRow(splits: List) { modifier = Modifier .fillMaxWidth() .height(6.dp) + .clip(TrackProShapes.pill) .background(color) ) Spacer(Modifier.height(5.dp)) @@ -903,27 +926,28 @@ private fun SectorSplitsRow(splits: List) { private fun RecentLapsPanel( laps: List, eventName: String, + isCircuit: Boolean, modifier: Modifier = Modifier ) { - Column(modifier = modifier.background(TrackProTheme.colors.panel)) { + Column(modifier = modifier.paddockCard()) { Row( modifier = Modifier .fillMaxWidth() - .padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 6.dp), + .padding(start = Spacing.lg, end = Spacing.lg, top = Spacing.md, bottom = Spacing.sm), horizontalArrangement = Arrangement.SpaceBetween ) { Text( - text = "LAST ${eventName}S", + text = if (isCircuit) stringResource(R.string.hud_last_laps) else stringResource(R.string.hud_last_runs), style = TrackProType.label, color = TrackProTheme.colors.markingDim ) Text( - text = "GAP TO BEST", + text = stringResource(R.string.hud_gap_to_best), style = TrackProType.label, color = TrackProTheme.colors.markingDim ) } - Bezel() + Bezel(Modifier.padding(horizontal = Spacing.lg)) if (laps.isEmpty()) { Box( @@ -933,7 +957,7 @@ private fun RecentLapsPanel( contentAlignment = Alignment.Center ) { Text( - text = "NO ${eventName}S YET", + text = if (isCircuit) stringResource(R.string.hud_no_laps) else stringResource(R.string.hud_no_runs), style = TrackProType.label, color = TrackProTheme.colors.markingDim ) @@ -1022,8 +1046,7 @@ private fun LapRow(lap: CompletedLap, isBest: Boolean, gapToBestMs: Long) { Layout( modifier = Modifier .fillMaxWidth() - .background(TrackProTheme.colors.field) - .padding(horizontal = 14.dp, vertical = 8.dp), + .padding(horizontal = Spacing.lg, vertical = Spacing.sm), content = { Row( modifier = Modifier.layoutId(SLOT_LEAD), @@ -1045,7 +1068,7 @@ private fun LapRow(lap: CompletedLap, isBest: Boolean, gapToBestMs: Long) { if (lap.signalGap) { Spacer(Modifier.width(8.dp)) Text( - text = "GPS GAP", + text = stringResource(R.string.lap_gps_gap), style = TrackProType.label, color = TrackProTheme.colors.danger ) @@ -1053,7 +1076,7 @@ private fun LapRow(lap: CompletedLap, isBest: Boolean, gapToBestMs: Long) { } if (isBest) { Text( - text = "BEST", + text = stringResource(R.string.lap_badge_best), style = TrackProType.label.atSize(11.sp), color = TrackProTheme.colors.accent, modifier = Modifier.layoutId(SLOT_GAP) @@ -1102,7 +1125,7 @@ private fun LapRow(lap: CompletedLap, isBest: Boolean, gapToBestMs: Long) { } } } - Bezel() + Bezel(Modifier.padding(horizontal = Spacing.lg)) } } diff --git a/app/src/main/java/com/example/trackpro/screens/vehicleScreens/CarCreatorScreen.kt b/app/src/main/java/com/example/trackpro/screens/vehicleScreens/CarCreatorScreen.kt index 30e5603..94f364f 100644 --- a/app/src/main/java/com/example/trackpro/screens/vehicleScreens/CarCreatorScreen.kt +++ b/app/src/main/java/com/example/trackpro/screens/vehicleScreens/CarCreatorScreen.kt @@ -1,5 +1,7 @@ package com.example.trackpro.screens.vehicleScreens +import com.example.trackpro.R +import androidx.compose.ui.res.stringResource import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -19,6 +21,7 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Timer import androidx.compose.runtime.Composable +import com.example.trackpro.components.specLabelMap import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -78,7 +81,7 @@ fun CarCreationScreen( val scrolled by scrollState.isScrolledUnderChrome() ScreenScaffold( - title = "Vehicle Setup", + title = stringResource(R.string.garage_add_car), onBack = onBack, accent = TrackProTheme.colors.accent, contentScrolled = scrolled @@ -97,37 +100,39 @@ fun CarCreationScreen( ) { AppCard(modifier = Modifier.fillMaxWidth(), padding = 20.dp) { - SectionLabel("Basic Info (Required)", modifier = Modifier.padding(vertical = Spacing.sm)) - CustomTextField("Manufacturer", manufacturer, leadingIcon = Icons.Default.Business) { manufacturer = it } - CustomTextField("Model", model, leadingIcon = Icons.Default.DirectionsCar) { model = it } - CustomTextField("Year", year, leadingIcon = Icons.Default.Event) { year = it } + SectionLabel(stringResource(R.string.creator_basics), modifier = Modifier.padding(vertical = Spacing.sm)) + CustomTextField(stringResource(R.string.creator_manufacturer), manufacturer, leadingIcon = Icons.Default.Business) { manufacturer = it } + CustomTextField(stringResource(R.string.creator_model), model, leadingIcon = Icons.Default.DirectionsCar) { model = it } + CustomTextField(stringResource(R.string.creator_year), year, leadingIcon = Icons.Default.Event) { year = it } - SectionLabel("Performance", modifier = Modifier.padding(vertical = Spacing.sm)) - CustomTextField("Horsepower", horsepower, true, Icons.Default.FlashOn) { horsepower = it } - CustomTextField("Torque (Nm)", torque, true, Icons.Default.Settings) { torque = it } - CustomTextField("Weight (kg)", weight, true, Icons.Default.FitnessCenter) { weight = it } - CustomTextField("Top Speed (${UnitFormatter.speedUnitLabel(useMetric)})", topSpeed, true, Icons.Default.Speed) { topSpeed = it } + SectionLabel(stringResource(R.string.car_performance), modifier = Modifier.padding(vertical = Spacing.sm)) + CustomTextField(stringResource(R.string.creator_horsepower), horsepower, true, Icons.Default.FlashOn) { horsepower = it } + CustomTextField(stringResource(R.string.creator_torque), torque, true, Icons.Default.Settings) { torque = it } + CustomTextField(stringResource(R.string.creator_weight), weight, true, Icons.Default.FitnessCenter) { weight = it } + CustomTextField(stringResource(R.string.creator_top_speed, UnitFormatter.speedUnitLabel(useMetric)), topSpeed, true, Icons.Default.Speed) { topSpeed = it } CustomTextField( - if (useMetric) "0-100 KM/H (s)" else "0-60 MPH (s)", + if (useMetric) "0-100 km/h (s)" else "0-60 mph (s)", acceleration, true, Icons.Default.Timer ) { acceleration = it } - CustomTextField("Fuel Capacity (L)", fuelCapacity, true, Icons.Default.LocalGasStation) { fuelCapacity = it } + CustomTextField(stringResource(R.string.creator_fuel_capacity), fuelCapacity, true, Icons.Default.LocalGasStation) { fuelCapacity = it } - SectionLabel("Configuration", modifier = Modifier.padding(vertical = Spacing.sm)) - AppDropdownField("Engine Type", jsonOptions.engineTypes, selectedEngineType, { it }, { selectedEngineType = it }) - AppDropdownField("Drivetrain", jsonOptions.drivetrains, selectedDrivetrain, { it }, { selectedDrivetrain = it }) - AppDropdownField("Fuel Type", jsonOptions.fuelTypes, selectedFuelType, { it }, { selectedFuelType = it }) - AppDropdownField("Tire Type", jsonOptions.tireTypes, selectedTireType, { it }, { selectedTireType = it }) - AppDropdownField("Transmission", jsonOptions.transmissions, selectedTransmission, { it }, { selectedTransmission = it }) - AppDropdownField("Suspension", jsonOptions.suspensionTypes, selectedSuspensionType, { it }, { selectedSuspensionType = it }) + SectionLabel(stringResource(R.string.creator_configuration), modifier = Modifier.padding(vertical = Spacing.sm)) + val spec = specLabelMap() + val shown: (String) -> String = { spec[it] ?: it } + AppDropdownField(stringResource(R.string.creator_engine_type), jsonOptions.engineTypes, shown(selectedEngineType), shown, { selectedEngineType = it }) + AppDropdownField(stringResource(R.string.car_drivetrain), jsonOptions.drivetrains, shown(selectedDrivetrain), shown, { selectedDrivetrain = it }) + AppDropdownField(stringResource(R.string.creator_fuel_type), jsonOptions.fuelTypes, shown(selectedFuelType), shown, { selectedFuelType = it }) + AppDropdownField(stringResource(R.string.car_tyres), jsonOptions.tireTypes, shown(selectedTireType), shown, { selectedTireType = it }) + AppDropdownField(stringResource(R.string.car_transmission), jsonOptions.transmissions, shown(selectedTransmission), shown, { selectedTransmission = it }) + AppDropdownField(stringResource(R.string.car_suspension), jsonOptions.suspensionTypes, shown(selectedSuspensionType), shown, { selectedSuspensionType = it }) Spacer(modifier = Modifier.height(Spacing.md)) PrimaryButton( - text = "Save Vehicle", + text = stringResource(R.string.creator_save), onClick = { if (manufacturer.isBlank() || model.isBlank() || year.isBlank()) { - Toast.makeText(context, "Fill in required fields.", Toast.LENGTH_SHORT).show() + Toast.makeText(context, context.getString(R.string.creator_missing), Toast.LENGTH_SHORT).show() return@PrimaryButton } @@ -155,9 +160,8 @@ fun CarCreationScreen( database.vehicleInformationDAO().insertVehicle(vehicle) } - Toast.makeText(context, "Vehicle saved successfully.", Toast.LENGTH_SHORT).show() + Toast.makeText(context, context.getString(R.string.creator_saved), Toast.LENGTH_SHORT).show() }, - accent = TrackProTheme.colors.deltaGood, modifier = Modifier.fillMaxWidth() ) } diff --git a/app/src/main/java/com/example/trackpro/theme/DataVizColors.kt b/app/src/main/java/com/example/trackpro/theme/DataVizColors.kt index 5531f58..b9b32c6 100644 --- a/app/src/main/java/com/example/trackpro/theme/DataVizColors.kt +++ b/app/src/main/java/com/example/trackpro/theme/DataVizColors.kt @@ -1,79 +1,66 @@ package com.example.trackpro.theme /** - * Fixed hex colours for MapLibre / MPAndroidChart / Canvas draw calls. + * Fixed hex colours for MapLibre / MPAndroidChart / Canvas draw calls, which run outside any + * `@Composable` and cannot read `TrackProTheme.colors`. Each value is a Paddock night token + * or a timing convention, named in its comment; change both together. * - * These run outside any `@Composable` context (Style and Layer objects, View callbacks, - * DrawScope), so they cannot read `TrackProTheme.colors` and have to be literals. They do - * not switch with the app's luminance setting: a map tile and a chart canvas are dark in - * both Night and Day, so these track the Night face only. - * - * **They are the dash palette, not a second one.** Every value below is a literal of a - * token in [NightDashColors] or of the sport's severity convention, and the comment names - * which. This file previously drifted: the chart line moved to the dash accent while the - * map layers were left on the old five-colour teal/yellow world, so a lap trace and the - * chart of that same lap disagreed about what colour "the subject" is. When a token in - * TrackProColors changes, the matching literal here changes with it. - * - * The one deliberate exception is the speed heatmap, which lives in `SpeedColorUtils` and - * runs green to red by category convention rather than by this palette. + * The speed heatmap lives in `SpeedColorUtils` and runs green to red by convention. */ object DataVizColors { - // ── Map: the track and its furniture ─────────────────── - /** The track itself - the subject of the map, so it takes `marking`. */ - const val trackLine = "#F2F4F3" + // ── Map ──────────────────────────────────────────────── + /** The track itself. (`textPrimary`) */ + const val trackLine = "#F4F5F7" - /** Track edges and boundaries - structural, so `bezel` lifted just clear of the tile. */ - const val boundaryLine = "#3A3F42" + /** Track edges. (`sectorLine`, lifted off the tile) */ + const val boundaryLine = "#3A3F4A" - /** Sector gates. Purple, because a sector line is a timing mark. (`accent`) */ - const val sectorMarker = "#B473E8" + /** Sector gates. (`accent`) */ + const val sectorMarker = "#FF6B1A" - /** Start. Green, per the severity convention. (`deltaGood`) */ - const val startMarker = "#3FD07A" + /** Start. (`deltaGood`) */ + const val startMarker = "#3DDC84" - /** Finish. Red is reserved for fault elsewhere; on a map it is simply the end. (`danger`) */ - const val endMarker = "#E5453A" + /** Finish. (`danger`) */ + const val endMarker = "#FF4D4F" - /** Dark ring behind map markers so they read against any tile. (`panel`) */ - const val darkOutline = "#0A0C0B" + /** Ring behind markers so they read on any tile. (`bgDeep`) */ + const val darkOutline = "#0F1115" - // ── Two overlaid lap traces ──────────────────────────── + // ── Two overlaid laps ────────────────────────────────── /** The lap under inspection. (`accent`) */ - const val seriesPrimary = "#B473E8" + const val seriesPrimary = "#FF6B1A" - /** The lap it is being compared against. (`deltaBad` - the reference, not a fault.) */ - const val seriesCompare = "#E8B33A" + /** The lap it is compared with: a cool blue, clearly not the accent and not a fault. */ + const val seriesCompare = "#5AB8FF" // ── Chart canvas ─────────────────────────────────────── - /** (`field`) */ - const val chartBackground = "#14171A" + /** (`bgCard`) */ + const val chartBackground = "#1A1D23" - /** (`bezel`) */ - const val chartGrid = "#2A2F31" + /** (`sectorLine`) */ + const val chartGrid = "#2A2E36" - /** (`markingDim`) */ - const val chartAxisText = "#9AA1A4" + /** (`textFaint`) */ + const val chartAxisText = "#868B95" /** (`accent`) */ - const val chartLine = "#B473E8" + const val chartLine = "#FF6B1A" // ── Gauge sweep ──────────────────────────────────────── - // Climbs through the severity convention as speed rises, so the sweep itself says - // "how hard are you going" without a legend. /** (`bgElevated`) */ - const val gaugeTrack = "#1E2225" + const val gaugeTrack = "#252932" /** (`deltaGood`) */ - const val gaugeLow = "#3FD07A" + const val gaugeLow = "#3DDC84" /** (`deltaBad`) */ - const val gaugeMid = "#E8B33A" + const val gaugeMid = "#FFB020" /** (`danger`) */ - const val gaugeHigh = "#E5453A" + const val gaugeHigh = "#FF4D4F" /** (`textFaint`) */ - const val gaugeTick = "#5E6669" + const val gaugeTick = "#868B95" } diff --git a/app/src/main/java/com/example/trackpro/theme/Spacing.kt b/app/src/main/java/com/example/trackpro/theme/Spacing.kt index c3657ae..a0a2dd5 100644 --- a/app/src/main/java/com/example/trackpro/theme/Spacing.kt +++ b/app/src/main/java/com/example/trackpro/theme/Spacing.kt @@ -1,25 +1,28 @@ package com.example.trackpro.theme +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.unit.dp +/** The 8-point grid, with 4 and 12 for the tight end. */ object Spacing { val xs = 4.dp val sm = 8.dp val md = 12.dp val lg = 16.dp val xl = 24.dp + val xxl = 32.dp + + /** Screen side margin: cards float inside it rather than running edge to edge. */ + val gutter = 16.dp } -/** - * Square, everywhere. - * - * The dash world is milled metal and glass: apertures are cut, not moulded, so nothing - * on the panel carries a radius. Kept as RoundedCornerShape rather than RectangleShape so - * the handful of call sites that address individual corners still compile. - */ +/** Soft, generous radii: cards 24, controls 16, chips 12, badges 8. */ object TrackProShapes { - val badge = RoundedCornerShape(0.dp) - val control = RoundedCornerShape(0.dp) - val card = RoundedCornerShape(0.dp) + val badge = RoundedCornerShape(8.dp) + val chip = RoundedCornerShape(12.dp) + val control = RoundedCornerShape(16.dp) + val card = RoundedCornerShape(24.dp) + val sheet = RoundedCornerShape(28.dp) + val pill = CircleShape } diff --git a/app/src/main/java/com/example/trackpro/theme/TrackProColors.kt b/app/src/main/java/com/example/trackpro/theme/TrackProColors.kt index 5565f11..eac3331 100644 --- a/app/src/main/java/com/example/trackpro/theme/TrackProColors.kt +++ b/app/src/main/java/com/example/trackpro/theme/TrackProColors.kt @@ -3,127 +3,128 @@ package com.example.trackpro.theme import androidx.compose.ui.graphics.Color /** - * TrackPro's colour world: a blackout race dash. + * TrackPro's colour world: **Paddock night**. * - * The reference is a purpose-built display - AiM, MoTeC, Racelogic - where the panel is - * unlit until it has something to say, markings are luminous rather than coloured, and - * nothing on the face is decorative. That discipline is the whole point: this world sits - * one careless step from the fake-carbon racing skin, and the thing that separates them is - * that every element here is a real readout. + * A dark graphite base, soft raised cards, and one racing-orange accent - the feel of a + * premium car maker's app rather than a lab instrument. It follows 60/30/10: graphite ground + * for most of every screen, near-white type and card tones for structure, orange reserved + * for the one thing you should press or notice. * - * **Night and Day are one face at two luminances, not two themes.** A real dash does not - * turn into a white document in sunlight; it drives its markings harder. So Day keeps the - * blackout ground, pushes it to true black for maximum contrast against glare, and lifts - * every marking. Anyone expecting a conventional light mode will find this surprising, and - * it is deliberate: a white screen on a windscreen mount at dusk is worse than useless. + * Text hierarchy comes from luminance steps of one near-white, not from extra colours: + * [textPrimary] for what you read, [textMuted] for what supports it, [textFaint] for + * metadata. Timing colours keep the sport's convention - green for up on your time, amber + * for down, red only for a genuine fault - because a driver already reads them that way. * - * Severity colour is the sport's own: purple for session best, green for personal best, - * amber for down on your own time. Red is reserved for genuine fault - never for "slower". - * - * Field names are unchanged from previous schemes so every screen inherits without a - * rename sweep; the world-native aliases below are what new code should read. + * The two schemes are one design at two contrasts. [PaddockNight] is the default; + * [PaddockSunlight] drops the ground to black and lifts every tone for a phone on a mount in + * direct sun. It is not a light theme. */ data class TrackProColorScheme( - /** The unlit panel. */ + /** Page ground: the 60%. */ val bgDeep: Color, - /** An instrument field raised out of the panel. */ + /** A card sitting on the ground. */ val bgCard: Color, - /** A field that is currently the subject. */ + /** Something raised off a card: selected, pressed, an input, a chip. */ val bgElevated: Color, - /** Session best. Purple, per timing convention. */ + /** Racing orange. Primary actions, the selected tab, key indicators. */ val accent: Color, - /** Knocked out of an [accent] fill. */ + /** Text and icons on an [accent] fill. */ val onAccent: Color, - /** Inactive segment, placard caps, structural marks. */ + /** Quiet icon and structure tone. */ val accentMuted: Color, val textPrimary: Color, val textMuted: Color, val textFaint: Color, - /** Personal best / up on your time. */ + /** Up on your time, healthy link, backed up. */ val deltaGood: Color, - /** Down on your own time. Amber, never red. */ + /** Down on your time, pending, needs attention. Amber, never red. */ val deltaBad: Color, - /** Bezel hairline and segment gaps. */ + /** Hairlines and dividers inside cards. */ val sectorLine: Color, - /** Genuine fault or destructive action only. */ + /** Genuine fault and destructive actions only. */ val danger: Color ) -// ── World-native aliases ─────────────────────────────────── +// ── Role aliases ─────────────────────────────────────────── +// Earlier code reads the colours by these names; they now mean the Paddock night roles. -/** The unlit panel behind everything. */ +/** The page ground. */ val TrackProColorScheme.panel: Color get() = bgDeep -/** An instrument field. */ +/** A card. */ val TrackProColorScheme.field: Color get() = bgCard -/** The field currently under the eye. */ +/** A raised or selected surface on a card. */ val TrackProColorScheme.fieldLive: Color get() = bgElevated -/** Luminous marking - numerals and legends. */ +/** Primary text. */ val TrackProColorScheme.marking: Color get() = textPrimary -/** A marking that is present but not being read. */ +/** Secondary text. */ val TrackProColorScheme.markingDim: Color get() = textMuted -/** An unlit segment: drawn, not absent, so the bar's full range stays visible. */ -val TrackProColorScheme.segmentOff: Color get() = sectorLine +/** An unfilled segment of a progress bar. */ +val TrackProColorScheme.segmentOff: Color get() = bgElevated -/** Bezel hairline between fields. */ +/** Hairline divider. */ val TrackProColorScheme.bezel: Color get() = sectorLine -/** - * Night: the default face. Dusk, garage, evening sessions, and anything indoors. - */ -val NightDashColors = TrackProColorScheme( - bgDeep = Color(0xFF0A0C0B), - bgCard = Color(0xFF14171A), - bgElevated = Color(0xFF1E2225), - - accent = Color(0xFFB473E8), - onAccent = Color(0xFF150E1C), - accentMuted = Color(0xFF9AA1A4), - - textPrimary = Color(0xFFF2F4F3), // 16.9:1 on the panel - textMuted = Color(0xFF9AA1A4), // 7.2:1 - textFaint = Color(0xFF5E6669), // 3.2:1 - large marks only - - deltaGood = Color(0xFF3FD07A), - deltaBad = Color(0xFFE8B33A), - sectorLine = Color(0xFF2A2F31), - danger = Color(0xFFE5453A) +/** The accent at 12%: secondary buttons, selected rows, soft highlights. */ +val TrackProColorScheme.accentSoft: Color get() = accent.copy(alpha = 0.14f) + +/** Tinted shadow colour for glows under accent elements. */ +val TrackProColorScheme.accentGlow: Color get() = accent.copy(alpha = 0.45f) + +/** Shadow colour for neutral cards: the ground's own hue, never pure black on graphite. */ +val TrackProColorScheme.cardShadow: Color get() = Color(0xFF000000).copy(alpha = 0.55f) + +/** A 1dp top highlight on raised elements - the "white inner shadow". */ +val TrackProColorScheme.highlight: Color get() = Color.White.copy(alpha = 0.06f) + +val PaddockNight = TrackProColorScheme( + bgDeep = Color(0xFF0F1115), + bgCard = Color(0xFF1A1D23), + bgElevated = Color(0xFF252932), + + accent = Color(0xFFFF6B1A), + onAccent = Color(0xFF1F0E04), + accentMuted = Color(0xFF8A8F98), + + textPrimary = Color(0xFFF4F5F7), // 16.2:1 on a card + textMuted = Color(0xFFB4B8C0), // 8.9:1 + textFaint = Color(0xFF868B95), // 4.8:1 + + deltaGood = Color(0xFF3DDC84), + deltaBad = Color(0xFFFFB020), + sectorLine = Color(0xFF2A2E36), + danger = Color(0xFFFF4D4F) ) -/** - * Day: the same face driven harder. - * - * Ground goes to true black because against direct glare the limiting factor is the - * difference between marking and ground, and every marking lifts toward full luminance. - * This is a backlight step, not an inversion - see the class note. - */ -val DayDashColors = TrackProColorScheme( +/** Paddock night pushed for glare: black ground, every tone lifted. */ +val PaddockSunlight = TrackProColorScheme( bgDeep = Color(0xFF000000), - bgCard = Color(0xFF0B0E10), - bgElevated = Color(0xFF16191C), + bgCard = Color(0xFF15171C), + bgElevated = Color(0xFF22252D), - accent = Color(0xFFD9A6FF), - onAccent = Color(0xFF14091F), - accentMuted = Color(0xFFC2C8CB), + accent = Color(0xFFFF7A2E), + onAccent = Color(0xFF1F0E04), + accentMuted = Color(0xFFA7ACB5), - textPrimary = Color(0xFFFFFFFF), // 21:1 - textMuted = Color(0xFFC2C8CB), // 12.4:1 - textFaint = Color(0xFF7C8488), // 4.8:1 + textPrimary = Color(0xFFFFFFFF), + textMuted = Color(0xFFD3D6DC), + textFaint = Color(0xFFA2A7B0), deltaGood = Color(0xFF4FF08C), - deltaBad = Color(0xFFFFC64A), - sectorLine = Color(0xFF333A3D), - danger = Color(0xFFFF5A47) + deltaBad = Color(0xFFFFC24A), + sectorLine = Color(0xFF31353E), + danger = Color(0xFFFF5C5E) ) -// Legacy names kept so existing call sites resolve unchanged. The app's existing -// dark/light toggle now selects night vs day luminance rather than two themes. -val DarkTrackProColors = NightDashColors -val LightTrackProColors = DayDashColors +// The app's dark/light toggle now picks standard vs sunlight contrast. +val DarkTrackProColors = PaddockNight +val LightTrackProColors = PaddockSunlight +val NightDashColors = PaddockNight +val DayDashColors = PaddockSunlight diff --git a/app/src/main/java/com/example/trackpro/theme/Type.kt b/app/src/main/java/com/example/trackpro/theme/Type.kt index 681eaa3..cf7b342 100644 --- a/app/src/main/java/com/example/trackpro/theme/Type.kt +++ b/app/src/main/java/com/example/trackpro/theme/Type.kt @@ -7,33 +7,18 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.sp -/** - * The dash's face. - * - * Intended face: a condensed grotesque with hard-edged, high-x-height numerals - the - * lettering a purpose-built display uses, where a 9 and an 8 can never be confused at a - * glance. Not bundled yet, so this resolves to the platform sans; swapping it is this one - * constant plus a file in `res/font/`. - */ +/** One family throughout. Hierarchy comes from size, weight and tone, not from a second face. */ val TrackProFontFamily: FontFamily = FontFamily.SansSerif /** - * Tabular, lining figures. - * - * `tnum` is not cosmetic on a live readout. Without it every digit has its own advance, so - * a running lap timer physically shifts left and right as the digits change - the number - * dances while you are trying to read it at speed. Fixed advances hold it still. + * Tabular, lining figures: the monospaced variant of the numerals. A running lap timer with + * proportional digits physically shifts as it counts; fixed advances hold it still. */ private const val TabularFigures = "tnum, lnum" /** - * Resize a style, scaling leading **and tracking** with it. - * - * Tracking is size-relative, not absolute. [displayNumeric] carries -2.2sp because that is - * right at 56sp; carried unchanged to 15sp the same -2.2sp is roughly -15% per character - * and the glyphs physically overlap. Scaling by the size ratio keeps the optical spacing - * the style was drawn with at every size, which is the whole reason this helper exists - * rather than a bare `copy(fontSize = ...)`. + * Resize a style, scaling leading and tracking with it, so a style keeps the spacing it was + * drawn with at any size. */ fun TextStyle.atSize(size: TextUnit): TextStyle { if (!fontSize.isSpecified || fontSize.value <= 0f) return copy(fontSize = size) @@ -48,74 +33,72 @@ fun TextStyle.atSize(size: TextUnit): TextStyle { } /** - * A dash's scale is brutally top-heavy: one readout dominates every frame and everything - * else is a placard beneath it. There is no display tier for prose, because a dash has no - * prose. + * Four sizes, two weights. + * + * - **Display** 40 SemiBold - the one number a screen exists to show. + * - **Title** 20 SemiBold - card titles, stat values, a car's name. + * - **Body** 15 Regular / SemiBold - running text, row labels. + * - **Caption** 12 Regular / SemiBold - supporting lines and metadata. + * + * Labels are sentence case; nothing is set in capitals. The live HUDs enlarge [displayNumeric] + * with [atSize] - reading at speed is the one place size outranks the scale. */ object TrackProType { - /** - * The readout. Delta on track, the split just crossed on the strip, the running clock - * everywhere else. Sized to be read peripherally, tracked tight because at this scale - * default spacing reads as gappy. - */ val displayNumeric = TextStyle( fontFamily = TrackProFontFamily, - fontSize = 56.sp, - lineHeight = 58.sp, // 1.04 - a lone figure needs no leading - fontWeight = FontWeight.Black, - letterSpacing = (-2.2).sp, + fontSize = 40.sp, + lineHeight = 46.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.8).sp, fontFeatureSettings = TabularFigures ) - /** A secondary instrument value: best, last, split, speed, count. */ + /** A stat value: best, last, split, speed, count. */ val statValue = TextStyle( fontFamily = TrackProFontFamily, fontSize = 20.sp, - lineHeight = 24.sp, - fontWeight = FontWeight.Bold, - letterSpacing = (-0.4).sp, + lineHeight = 26.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.2).sp, fontFeatureSettings = TabularFigures ) - /** A field's own name, or the car on its placard. */ + /** Card titles, a car's name, a screen's own heading. */ val titleLarge = TextStyle( fontFamily = TrackProFontFamily, - fontSize = 16.sp, - lineHeight = 20.sp, - fontWeight = FontWeight.Bold, - letterSpacing = 0.3.sp, + fontSize = 20.sp, + lineHeight = 26.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = (-0.1).sp, fontFeatureSettings = TabularFigures ) - /** A row label. */ + /** Row labels and emphasised body. */ val titleMedium = TextStyle( fontFamily = TrackProFontFamily, - fontSize = 13.sp, - lineHeight = 18.sp, - fontWeight = FontWeight.Medium, - letterSpacing = 0.2.sp, + fontSize = 15.sp, + lineHeight = 22.sp, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.sp, fontFeatureSettings = TabularFigures ) - /** - * The placard cap under every instrument. Always upper case at the call site, tracked - * open because small caps close up and a placard has to be legible without being read. - */ + /** Captions, metadata, the name under a stat. Sentence case. */ val label = TextStyle( fontFamily = TrackProFontFamily, - fontSize = 10.sp, - lineHeight = 14.sp, - fontWeight = FontWeight.Medium, - letterSpacing = 1.4.sp, + fontSize = 12.sp, + lineHeight = 16.sp, + fontWeight = FontWeight.Normal, + letterSpacing = 0.1.sp, fontFeatureSettings = TabularFigures ) - /** Running text, where any survives. */ + /** Running text. */ val body = TextStyle( fontFamily = TrackProFontFamily, - fontSize = 13.sp, - lineHeight = 19.sp, + fontSize = 15.sp, + lineHeight = 22.sp, fontWeight = FontWeight.Normal, letterSpacing = 0.sp, fontFeatureSettings = TabularFigures diff --git a/app/src/main/java/com/example/trackpro/viewModels/DragRecordingViewModel.kt b/app/src/main/java/com/example/trackpro/viewModels/DragRecordingViewModel.kt index 96c53fe..1347210 100644 --- a/app/src/main/java/com/example/trackpro/viewModels/DragRecordingViewModel.kt +++ b/app/src/main/java/com/example/trackpro/viewModels/DragRecordingViewModel.kt @@ -1,5 +1,6 @@ package com.example.trackpro.viewModels +import com.example.trackpro.R import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel @@ -218,7 +219,7 @@ class DragRecordingViewModel(context: Context) : ViewModel() { if (points.isNotEmpty()) database.rawGPSDataDao().insertAll(points) } catch (e: Exception) { Log.e(TAG, "Failed to save drag run", e) - _saveError.value = e.message ?: "The run could not be written to the database" + _saveError.value = e.message ?: app.getString(R.string.drag_save_failed) } } } diff --git a/app/src/main/java/com/example/trackpro/viewModels/SessionViewModel.kt b/app/src/main/java/com/example/trackpro/viewModels/SessionViewModel.kt index a45046d..ed751f9 100644 --- a/app/src/main/java/com/example/trackpro/viewModels/SessionViewModel.kt +++ b/app/src/main/java/com/example/trackpro/viewModels/SessionViewModel.kt @@ -8,8 +8,10 @@ import com.example.trackpro.dataClasses.SessionData import com.example.trackpro.managerClasses.ESPDatabase import com.example.trackpro.models.DragSessionWithVehicle import com.example.trackpro.models.LoadState +import com.example.trackpro.managerClasses.utilities.toLapTimeMillisOrNull import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch class SessionViewModel(private val database: ESPDatabase) : ViewModel() { @@ -21,6 +23,10 @@ class SessionViewModel(private val database: ESPDatabase) : ViewModel() { private val _loadState = MutableStateFlow(LoadState.Loading) val loadState = _loadState.asStateFlow() + /** Each session's best completed lap in ms, by session id. Sessions without one are absent. */ + private val _bestLapMs = MutableStateFlow>(emptyMap()) + val bestLapMs = _bestLapMs.asStateFlow() + private val _sessionsWithVehicles = MutableStateFlow>(emptyList()) val sessionsWithVehicle = _sessionsWithVehicles.asStateFlow() @@ -39,6 +45,17 @@ class SessionViewModel(private val database: ESPDatabase) : ViewModel() { _loadState.value = LoadState.Failed("Could not read sessions") } } + viewModelScope.launch { + // Only for ordering and display, so a failure here leaves the list without lap + // times rather than failing it. + database.lapTimeDataDAO().getBestLapPerSession() + .catch { /* keep whatever was last read */ } + .collect { rows -> + _bestLapMs.value = rows.mapNotNull { row -> + row.bestLap.toLapTimeMillisOrNull()?.let { row.sessionId to it } + }.toMap() + } + } viewModelScope.launch { database.sessionDataDao().getAllTrackSessionsWithVehicles().collect { _sessionsWithVehicles.value = it diff --git a/app/src/main/java/com/example/trackpro/viewModels/TimeAttackViewModel.kt b/app/src/main/java/com/example/trackpro/viewModels/TimeAttackViewModel.kt index a9cc93e..f71e730 100644 --- a/app/src/main/java/com/example/trackpro/viewModels/TimeAttackViewModel.kt +++ b/app/src/main/java/com/example/trackpro/viewModels/TimeAttackViewModel.kt @@ -52,6 +52,14 @@ class TimeAttackViewModel( // Timing state private var timingManager: TimingManager? = null + /** + * The driver's best on this track before this session started, or null if they have never + * timed a lap here. Read once at load, before this session has written a lap, so the + * summary can tell a new personal best apart from a good day. + */ + private val _priorTrackBestMs = MutableStateFlow(null) + val priorTrackBestMs = _priorTrackBestMs.asStateFlow() + private val _timingMode = MutableStateFlow(TimingMode.Circuit) val timingMode: StateFlow = _timingMode.asStateFlow() @@ -159,6 +167,11 @@ class TimeAttackViewModel( if (trackLoaded) return trackLoaded = true _timingMode.value = mode + viewModelScope.launch(Dispatchers.IO) { + _priorTrackBestMs.value = database.lapTimeDataDAO().getCountedLapTimesForTrack(trackId) + .mapNotNull { it.toLapTimeMillisOrNull() } + .minOrNull() + } viewModelScope.launch { database.trackCoordinatesDao().getCoordinatesOfTrack(trackId) .collect { coords -> @@ -270,6 +283,8 @@ class TimeAttackViewModel( database.lapTimeDataDAO().completeLap(_lapId, lapTimeStr, lap.signalGap) Log.d("TimeAttack", "Lap $_lapId COMPLETED with time $lapTimeStr" + if (lap.signalGap) " (GPS gap during lap)" else "") + // A joined event's board takes the lap now, not when the session ends. + app.online.onLapCompleted(_sessionId, app.applicationScope) // 2. For circuits, immediately start the next lap if (_timingMode.value is TimingMode.Circuit) { @@ -316,6 +331,7 @@ class TimeAttackViewModel( // Mark the current sprint as COMPLETED, with whether GPS dropped out during it database.lapTimeDataDAO().completeLap(_lapId, sprintTimeStr, run.signalGap) Log.d("TimeAttack", "Sprint $_lapId COMPLETED with time $sprintTimeStr") + app.online.onLapCompleted(_sessionId, app.applicationScope) // Don't create a new lap for sprints - user manually starts each run } catch (e: Exception) { diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml new file mode 100644 index 0000000..7442f46 --- /dev/null +++ b/app/src/main/res/values-hu/strings.xml @@ -0,0 +1,679 @@ + + + Beállítások + Nyelv + Rendszer + A telefon nyelvét követi + Hardver és érzékelők + Megjelenés + Napfény-kontraszt + Be: fekete háttér, világosabb szöveg erős napfényhez + Ki: normál Paddock night + Be + Ki + Mértékegységek + Sebesség és távolság + Metrikus (km/h, km) + Angolszász (mph, mi) + Legyen mph + Legyen km/h + Időmérés + Élő delta + A pályán futott legjobb köröd ellen + Az aktuális menet legjobb köre ellen + Menet legjobbja + Pálya legjobbja + Alkalmazás + Verzió + Térkép- és pályaadatok + © OpenStreetMap közreműködők + GPS-forrás + Wi-Fi + Bluetooth + Telefon + Válassz párosított eszközt + Bluetooth-eszköz + Nincs párosított eszköz – előbb párosítsd az ESP32-t az Android Bluetooth-beállításaiban + Bluetooth-engedély kell – koppints a Wi-Fi-re, majd újra a Bluetooth-ra, és a rendszer ismét megkérdez + ESP-cél + Teszt-szimulátor + Valódi eszköz (192.168.4.1) + Valódi eszköz + Teszt-szimulátor + Szimulátor IP-címe + pl. 192.168.1.50 + GPS-frissítés + · megerősítve + · az eszköz %1$d Hz-en fut + + %d autó + %d autó + + + %d pálya + %d pálya + + + %d menet + %d menet + + + %d futam + %d futam + + + %d kör + %d kör + + Betöltés + Nem sikerült betölteni + Újra + Vissza + Törlés + Mégse + Érvénytelen + Becsukás + Kinyitás + A menet nem mentődött el + Menet vége + Menet megtartása + Érvénytelenítés + Az érvénytelenített menet megmarad, de jelölve, és nem számít bele a legjobb idődbe. + Első idő ezen a pályán + Új egyéni csúcs + %1$s mp-cel gyorsabb az eddigi legjobbadnál ezen a pályán + Az első mért köröd ezen a pályán. Mostantól ezt kell megverni. + Vezetés + Előzmények + Események + Garázs + Profil + Mindig + Elmúlt 7 nap + Elmúlt 30 nap + Idén + Legújabb elöl + Legrégebbi elöl + Leggyorsabb kör + Minden autó + Törölt autó + Szűrés autóra + Minden pálya + Törölt pálya + Szűrés pályára + Szűrés dátumra + Rendezés + Kiválasztva + Törlés + Pálya + Gyorsulás + Előzmények + Pilóta + Köridőmérés, élő delta + 0–100, negyed mérföld + Utolsó menet + Pályás menetek + Gyorsulási futamok + Jó reggelt + Jó napot + Jó estét + GPS csatlakozva + Nincs jel + Add hozzá az autódat + Minden menet egy autóhoz tartozik + Még nincs kör + Az első pályás menet után itt látod a legjobb köröd + Legjobb kör · %1$s + utolsó menet + %1$s mp az előző itteni menetedhez képest + Ezen a telefonon + Mentésre vár + Mentve + Sikertelen mentés + Csak ezen a telefonon + Autók + Pályák + Garázs + Törlöd: %1$s? + Nincs rögzített menete. + A fiókodból is törlődik. + Ez nem vonható vissza. + Add hozzá az első autódat + Minden kör ahhoz az autóhoz kerül, amellyel megfutottad, így itt kezdődik minden. + Garázs betöltése + Autó hozzáadása + Nincs mentés. Jelentkezz be, hogy a garázsod biztonságban legyen. + A garázs mentve a fiókodba + %1$d / %2$d mentve, a többi a következő szinkronnal megy + Bejelentkezés + Fő + + A(z) %d rögzített menete és a körei is törlődnek. + A(z) %d rögzített menete és a körei is törlődnek. + + + %d autót nem sikerült menteni + %d autót nem sikerült menteni + + Ezt a fájlt nem sikerült képként beolvasni. + Fotó készítése + Választás + Eltávolítás + Jelentkezz be a mentéshez + Mentés újra + Legjobbak ezzel az autóval + Még nem járt pályán + Még nincs mért kör + Autó törlése + Pályás eredmények + Még nincs pályás menet + Válassz pályát és autót, és minden köröd ide kerül + Menetek betöltése + Pályás menet indítása + Nincs ilyen menet + Semmi sem felel meg ezeknek a szűrőknek + Szűrők törlése + Ismeretlen pálya + Törlöd a menetet? + A(z) %1$s pályán %2$s napon futott menet minden körrel és szektoridővel együtt véglegesen törlődik. + Ismeretlen autó + %1$s · legjobb %2$s + %1$d / %2$s + Gyorsulási eredmények + Még nincs gyorsulási futam + Mérd a 0–100-at és a negyed mérföldet, és minden futamod itt találod + Futamok betöltése + Gyorsulási futam indítása + Nincs ilyen futam + A(z) %1$s-kor indult futam és minden rögzített adata véglegesen törlődik. + Futam %1$s-kor + Pályáim + Pálya építése + Járd vagy vezesd végig, vagy rajzold meg a térképen + Még nincs pálya + Építs egy pályát, és itt megjelenik + Pályák betöltése + Törlöd a pályát? + A(z) %1$s és minden rajta rögzített kör véglegesen törlődik. + Hossz + Rekordod + Benzin + Dízel + Elektromos + Hibrid + Etanol + Hidrogén + Metanol + Utcai + Félslick + Slick + Négyévszakos + Lágy keverék + Közepes keverék + Kemény keverék + Esőgumi + Drag radiál + Terepgumi + Manuális + Automata + Szekvenciális + Duplakuplungos + CVT + Direkt hajtás + Független + Kettős keresztlengőkar + Tolórudas + Többlengőkaros + Hosszlengőkar + Csatolt hátsó híd + Aktív felfüggesztés + Hidropneumatikus + Merev híd + Soros 3 + Soros 4 + Soros 5 + Soros 6 + Wankel 2 + Körpálya + Sprint + Autó + Autó betöltése + Teljesítmény + Teljesítmény + Nyomaték + Tömeg + Végsebesség + Hajtás + Adatok + Motor + Váltó + Üzemanyag + Tank + Felfüggesztés + Gumik + Alapadatok (kötelező) + Gyártó + Modell + Évjárat + Lóerő + Nyomaték (Nm) + Tömeg (kg) + Végsebesség (%1$s) + Tank (l) + Felépítés + Motortípus + Üzemanyag + Autó mentése + Előbb add meg a gyártót, a modellt és az évjáratot. + Az autó bekerült a garázsodba. + Nincs választható elem + Nincs pálya + Betöltés… + Pálya + Típus + Pályapontok + Pálya betöltése + Ehhez a pályához nincs nyomvonal + Koordináták nélkül mentődött. Építsd újra a pályaépítőben, hogy lásd a térképet és szektorokat rögzíthess. + Szektorok + %1$d megjelölve + Nincs megjelölve + Szektorok törlése + Menet beállítása + Nincs kiválasztva + Válassz pályát + Válassz autót + Még nincs autó a garázsban + Menet indítása + Időmérés élesítése, indulhatsz + Előbb válassz pályát + Előbb válassz autót + Pálya kész + Pálya kell + Autó kész + Autó kell + Ismeretlen + Derült + Többnyire derült + Részben felhős + Borult + Köd + Szitálás + Ónos szitálás + Gyenge eső + Eső + Erős eső + Ónos eső + Gyenge havazás + Havazás + Erős havazás + Hódara + Záporok + Hózáporok + Zivatar + Zivatar jégesővel + Menet betöltése + A menet nem található + Javul ↑ + Romlik ↓ + Egyenletes → + Menet + Fő számok + Legjobb kör + Átlag + Leglassabb + Elméleti legjobb + Elméleti kör + Benne hagyott idő + Minden körből a legjobb szektorod, összerakva. %2$d körből %1$d alapján, amelyeknél minden részidő megvan. + Menet adatai + Teljes idő + Körök + Egyenletesség (σ) + Legjobb és leglassabb között + Tendencia + Körülmények + Időjárás + Pályafelület + Vizes + Száraz + Levegő hőmérséklete + Páratartalom + Szél + Légnyomás + Ebben a menetben nincs mért kör + Menet visszaállítása + Menet érvénytelenítése + Újra beleszámít a legjobbjaidba + Megmarad és jelölve lesz, de nem számít bele a legjobbjaidba + Legjobb + %1$d. kör + GPS-kiesés + Csúcs %1$s %2$s + Nincs sebességadat + Referencia + Sebesség + Vonal + A kör nem található + %1$d. kör · %2$s + Összevetés · %1$d. kör + Összevetés megszüntetése + Adatok elrejtése + Kör adatai + Összevetés + Összevetés + Bezárás + Köridő + Átlagsebesség + GPS-pontok + %1$d. szektor + Delta + Összevetés ezzel + Kör betöltése + Futam + Időtartam + Távolság + Levegő + Max. gyorsulás + Szintkülönbség + Emelkedő ↑ + Lejtő ↓ + ¼ mérföld + Célsebesség + GPS-nyomvonal + Sebesség + Grafikon + Térkép + Nincs GPS-adat + Nyomvonal betöltése + Ehhez a futamhoz nincs rögzített GPS-nyomvonal + Számolás + Delta a legjobbhoz + Delta a legjobbhoz – élő + Delta a pálya legjobbjához + Delta a pálya legjobbjához – élő + Delta a menet legjobbjához + Delta a menet legjobbjához – élő + Delta a menet legjobbjához – még nincs pályacsúcs + Nem sikerült betölteni a pályát vagy elindítani a menetet + Futamok + Utolsó + Rögzített szektorok + Befejezed a menetet? + Az időmérés leáll, és a menet bekerül az archívumba. A következő képernyőn érvénytelenítheted, ha nem kellene számítania. + Menet befejezése + Vezetek tovább + A menet nem indult el + Nem sikerült elindítani az időmérést + Vissza + Kör + Futam + Aktuális kör + Aktuális futam + Élő + Viszonyítás + Menet legjobbja + Pálya legjobbja + Szakasz + Utolsó körök + Utolsó futamok + Lemaradás a legjobbtól + Még nincs kör + Még nincs futam + Negyed mérföld + Legjobb részidő + Leállítod a rögzítést? + A futam bekerül az archívumba. A következő képernyőn érvénytelenítheted, ha nem kellene számítania. + Leállítás + Rögzítés folytatása + %1$d. futam + GPS rendben + Adatok + Látható + Rejtett + Válassz autót + 1/4 MÉRFÖLD + 1/2 MÉRFÖLD + %1$s · másodperc + Élő sebesség · %1$s + Rögzítés + Készenlét + 1/4 mérföld + 1/4 célseb. %1$s + 1/2 mérföld + Sebességprofil + A sebesség itt jelenik meg, amint elindítasz egy futamot + Válassz autót + Gyorsulás indítása + Minden futam egy autóhoz tartozik + Élesítsd az időmérőt, és indulj + Sebesség · %1$s + Max. + + Rögzítés · %d futam + Rögzítés · %d futam + + A futamot nem sikerült elmenteni az adatbázisba + Menet rögzítése + Időmérés közben látható, hogy kikapcsolt képernyővel is fusson. + Menet rögzítése folyamatban + Az időmérés kikapcsolt képernyővel is folytatódik. + Nincs kiválasztva + ESP32 (Wi-Fi) mód + ESP32 (Bluetooth) mód + Telefonos GPS mód + Módosítás + Sebesség · %1$s + Forrás + Telefonos GPS + Kapcsolat + Nincs kapcsolat + Pozíció + Megvan + Keresés + Újracsatlakozás + Forrás módosítása + Hibaüzenet: %1$s + Adatfolyam + Távoli IP (teszt) + Távoli IP + Párosított eszköz + Szélesség + Hosszúság + Magasság + Frissítés + %1$d Hz (függőben) + Nyers csomag + Várakozás az adatfolyamra… + Csatlakozva, nincs műholdas pozíció + A modul csatlakozik és küld adatot, de még nem talált műholdakat. Ehhez szabad égbolt kell, és hidegindításnál egy percig is eltarthat. + Senki sem válaszol itt: %1$s:%2$d + A telefon elérte a címet, de a(z) %1$d-es porton senki sem válaszolt. Az ESP32 kap áramot, de lehet, hogy nem fut rajta a firmware – töltsd fel újra a TrackPro_ESP repóból. + Nem érhető el: %1$s + A telefon egyáltalán nem érte el a modult. Csatlakozz az ESP32 Wi-Fi-hálózatához az Android beállításaiban – mobilneten keresztül nem érhető el. + Nincs kapcsolat: %1$s:%2$d + Ellenőrizd, hogy az ESP32 kap-e áramot, és hogy a telefon az ő Wi-Fi-hálózatára csatlakozik, nem az otthonira vagy a mobilnetre. + A soros kapcsolat nincs nyitva + Ellenőrizd, hogy az ESP32 kap-e áramot, és párosítva van-e még az Android Bluetooth-beállításaiban. A leválasztott vagy visszaállított modult újra párosítani kell, mielőtt itt megjelenik. + A telefon GPS-e nem küld adatot + Engedélyezd a helyhozzáférést a TrackPro számára, és menj ki szabad ég alá. A telefon GPS-e jóval ritkábban mér, mint az ESP-egység, így az idők is pontatlanabbak. + Pályaépítő + Rajt + Rajt / cél + Célvonal + %1$d. útpont + Pálya beállításai + Névtelen pálya + Szerkesztés + %1$d. szektor megjelölése + Igazítás az utakhoz… + ! Az utolsó szakaszt nem sikerült útvonalra illeszteni – egyenes vonal lett + Élő GPS + Kézi rajzolás + Rögzítés leállítása + GPS-rögzítés indítása + Rögzítés · járd végig a pályát + Utolsó visszavonása + Pálya mentése + Pálya adatai + Pálya neve + Ország + Időmérés módja + Kész + Még nem volt szinkron + Nem volt kapcsolat (%1$s) · később újrapróbálja + %1$d elküldve + %1$d visszaállítva + %1$d visszavonva + %1$d naprakész + %1$d sikertelen + Minden naprakész + Fő autó + Egyéni csúcsok + Fiók + Alkalmazás + Profilkép + Ezen a telefonon versenyez + Pilóta %1$s óta + TrackBoard-pilóta + Jelentkezz be, hogy mentsd a garázsod, és lásd, hol állsz az egyes pályákon. + Mért körök %1$s óta + Mért körök + Menetek + Megtett km + Minden kör ahhoz az autóhoz kerül, amellyel megfutottad + a fiókodból + Még nincs egyéni csúcs + Minden pályán a legjobb időd ide kerül egy mért kör után + A helyezéseid akkor jelennek meg, ha bejelentkezel és megosztod a köreidet. + Az exportálás elkészült + Profil szerkesztése + Név, fotó, ország, bemutatkozás + Körök megosztása a ranglistákon + A köreid fent vannak a ranglistákon + A köreid privátak maradnak + Megosztásra kerül: a neved, a kör- és szektoridők, a menet dátuma, az autó gyártója, modellje és évjárata, valamint hogy melyik GPS mérte. Csak előre megadott vagy közzétett pályákon futott menetek. Kikapcsolva le is kerülnek. + Szinkronizálás + Szinkronizálás… + Szinkron most + Adataid exportálása + Minden, amit a szerver tárol, egyetlen JSON-fájlban + Kijelentkezés + Az adataid ezen a telefonon maradnak + Fiók törlése + Véglegesen törli a szerverről + Törlöd a TrackBoard-fiókodat? + A profilod, a mentett autóid, a fotóid és minden megosztott köröd törlődik a szerverről, és lekerülsz minden ranglistáról. A telefonon lévő autók, pályák és menetek érintetlenek maradnak. + TÖRLÉS + A megerősítéshez írd be: %1$s + Jelszavad + Törlés… + + %d pilóta közül + %d pilóta közül + + Nem kötelező fiók + A garázsod, a pályáid és a megosztott meneteid mentve, és új telefonon visszaállítva + A helyezésed minden pálya ranglistáján, az egyéni csúcsod mellett + Nem kötelező. A rögzítéshez soha nem kell fiók vagy internet + Szerver · %1$s + nincs beállítva + A fotó cseréjéhez internetkapcsolat kell. %1$s + A fotó eltávolításához internetkapcsolat kell. %1$s + A mentéshez internetkapcsolat kell. %1$s + Feltöltés… + Fotó választása + Fotó eltávolítása + Adatok + Név a ranglistákon + Bemutatkozás + Mentés… + Mentés + Online · TrackBoard + Bejelentkezve: %1$s. A fiókod, a körmegosztás és a szinkron a Profil fülön van. + Nem kötelező. A Profil fülön bejelentkezve mentheted a garázsod és feltöltheted a köreidet a ranglistákra. A rögzítéshez soha nem kell fiók vagy internet. + Szerver + pl. http://10.0.2.2:5000 + Fiók létrehozása + E-mail + Jelszó (legalább 12 karakter) + Jelszó + Előbb adj meg egy szervert. + Folyamatban… + Közös pálya · minden TrackPro-pilóta + Te tetted közzé + Privát · tedd közzé, hogy ranglistája legyen + Privát · a közzétételhez jelentkezz be + Ranglista + Visszavonás + Közzététel + Megnyitás + A TrackBoard nem érhető el. + A TrackBoard elutasította a kérést. + Még senki nem töltött fel kört + A körök akkor jelennek meg, ha megosztást engedélyező pilóták mentek ezen a pályán. + Letöltés + A legjobb időd + A köridőket a pilóták töltik fel, nincsenek ellenőrizve. A telefon GPS-e pontatlanabb, mint egy ESP32-modul. + %1$s · TE + Versenyezz a barátaid köreivel + A TrackBoard pályanapjain minden pilóta köre egy élő táblára kerül. Jelentkezz be, majd csatlakozz a szervezőtől kapott kóddal. + Nincs kapcsolat. A telefonon már ismert események látszanak. + Ezen a TrackBoard szerveren még nincsenek események. + Csatlakozás eseményhez + Eseményeid + Események letöltése… + Még nincs. Ha egy szervezőtől kódot kapsz, írd be fent. + Pályanapot szervezel? Hozd létre az eseményt a TrackBoard weboldalán, és oszd meg a kódját. + Most élő + / %1$d + Még nincs köröd. Menj egy menetet itt: %1$s; minden kör azonnal felkerül, amint áthaladsz a vonalon. + Előbb töltsd le ezt a pályát: %1$s, hogy a köreidet ugyanazokon a kapukon mérje, mint a többiekét. + Élő tábla megnyitása + Írd be a szervezőtől kapott 6 karakteres kódot. + Nincs ilyen kódú esemény. Ellenőrizd a szervezővel. + A csatlakozáshoz internetkapcsolat kell. %1$s + Csatlakozási kód + Keresés + %1$s · %2$s · szervező: %3$s + Csoportod + Csatlakozáskor az esemény alatt itt (%1$s) futott minden köröd felkerül a táblára, a privát menetek is. + Csatlakozás… + Csoport módosítása + Csatlakozás + Ez az esemény véget ért. + Ez az esemény véget ért, de a táblája még megnyitható. + Szervező + Hamarosan + Befejeződött + még %1$d ó %2$d perc + még %1$d perc + Ez az esemény már nem létezik. + Esemény + Összesített + Még senki sem csatlakozott + Ebben a csoportban még nincs senki + A pilóták az Események fülön csatlakoznak a szervező kódjával. A köreik menet közben jelennek meg itt. + Tábla letöltése + A(z) %1$s már a telefonon van. Válaszd ki, amikor menetet indítasz. + A pálya letöltéséhez internetkapcsolat kell. + Az esemény alatt minden csatlakozott pilóta minden köre számít ezen a pályán, a privát menetek is. Az időket a pilóták töltik fel, nincsenek ellenőrizve. + Kilépés és a köreim eltávolítása a tábláról + Nem sikerült kilépni az eseményből. + Kilépés az eseményből + Te vagy a leggyorsabb + Te, %1$d közül + %1$s az első helyhez + Pályán + %1$s · te + Nincs kör + Utolsó kör: egyéni csúcs + Az esemény pályájának letöltése + A(z) %1$s még nincs a telefonon. Töltsd le, hogy a köreidet ugyanazokon a kapukon mérje, mint a többiekét. + Letöltés… + %1$s letöltése + Mértékegységek, kontraszt, GPS-forrás, szerver + GPS-egység kapcsolata + ESP32-kapcsolat és hibakeresés + Nincs pálya-nyomvonal + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..5a86e41 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,6 @@ #FF018786 #FF000000 #FFFFFFFF + #FF0F1115 + #FFFF6B1A \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9fca21e..996fc9a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,679 @@ - TrackPro + TrackPro + Settings + Language + System + Follows your phone\'s language + Hardware & Sensors + Appearance + Sunlight contrast + On: black ground, brighter text for direct sun + Off: standard Paddock night + On + Off + Units + Speed & Distance + Metric (km/h, km) + Imperial (mph, mi) + Use mph + Use km/h + Timing + Live Delta + Against your best lap on the track + Against this session\'s best lap + Use session best + Use track best + Application + App version + Map & Track Data + © OpenStreetMap contributors + GPS source + WiFi + Bluetooth + Phone + Select a paired device + Bluetooth Device + No paired devices — pair the ESP32 in Android Bluetooth settings first + Bluetooth permission needed — tap WiFi then Bluetooth again to re-prompt + ESP target + Test Simulator + Real Device (192.168.4.1) + Use Real Device + Use Test Simulator + Simulator IP Address + e.g. 192.168.1.50 + GPS rate + · confirmed + · device at %1$d Hz + + %d car + %d cars + + + %d track + %d tracks + + + %d session + %d sessions + + + %d run + %d runs + + + %d lap + %d laps + + Reading + Read failed + Retry + Back + Delete + Cancel + Voided + Collapse + Expand + Session not saved + Session complete + Keep session + Void this run + A voided run is kept but marked, and stops counting toward your bests. + First time on the board + New personal best + %1$s s faster than your previous best on this track + Your first timed lap on this track. That\'s the one to beat now. + Drive + History + Events + Garage + Profile + All time + Last 7 days + Last 30 days + This year + Newest first + Oldest first + Fastest lap + All cars + Removed car + Filter by car + All tracks + Removed track + Filter by track + Filter by date + Sort + Selected + Clear + Track + Drag + History + Driver + Lap timing, live delta + 0–100, quarter mile + Last session + Track sessions + Drag runs + Good morning + Good afternoon + Good evening + Rig linked + No signal + Add your car + Every session is timed against a car + No laps yet + Your best lap lands here after your first track session + Best lap · %1$s + last session + %1$s s vs your previous session here + On this phone + Waiting to back up + Backed up + Backup failed + Only on this phone + Cars + Tracks + Garage + Delete %1$s? + It has no recorded sessions. + It is also removed from your account. + This cannot be undone. + Add your first car + Every lap is filed against the car that set it, so this is where it starts. + Reading garage + Add car + Not backed up. Sign in to keep your garage safe. + Garage backed up to your account + %1$d of %2$d backed up, the rest go on the next sync + Sign in + Main + + Its %d recorded session and its laps are deleted with it. + Its %d recorded sessions and their laps are deleted with it. + + + %d car could not be backed up + %d cars could not be backed up + + That file could not be read as a picture. + Take photo + Choose + Remove + Sign in to back it up + Back up again + Bests in this car + Not driven on a track yet + No timed laps yet + Delete car + Track Records + No track sessions yet + Pick a track and a car, and every lap lands here + Reading sessions + Start a track session + No sessions match + Nothing recorded fits these filters + Clear filters + Unknown Track + Delete session? + The %1$s session from %2$s, including every lap and sector time, will be permanently removed. + Unknown car + %1$s · best %2$s + %1$d of %2$s + Drag Records + No drag runs yet + Time your 0-100 and quarter mile, then find every run here + Reading runs + Start a drag run + No runs match + The run at %1$s and all of its recorded data will be permanently removed. + Run at %1$s + My Tracks + Build a track + Walk or drive it, or draw it on the map + No tracks yet + Build a track to see it here + Reading tracks + Delete track? + %1$s and every lap recorded on it will be permanently removed. + Length + Your record + Petrol + Diesel + Electric + Hybrid + Ethanol + Hydrogen + Methanol + Street + Semi-Slick + Slick + All-Weather + Soft Compound + Medium Compound + Hard Compound + Wet Tires + Drag Radials + Off-Road + Manual + Automatic + Sequential + Dual-Clutch + CVT + Direct-Drive + Independent + Double Wishbone + Pushrod + Multilink + Trailing Arm + Torsion Beam + Active Suspension + Hydropneumatic + Solid Axle + Inline-3 + Inline-4 + Inline-5 + Inline-6 + Rotary-2 + Circuit + Sprint + Car + Loading vehicle + Performance + Power + Torque + Weight + Top speed + Drivetrain + Specs + Engine + Transmission + Fuel + Fuel capacity + Suspension + Tyres + Basics (required) + Manufacturer + Model + Year + Horsepower + Torque (Nm) + Weight (kg) + Top speed (%1$s) + Fuel capacity (l) + Configuration + Engine type + Fuel type + Save car + Add the make, model and year first. + Car saved to your garage. + No options available + No tracks found + Loading... + Track + Type + Track points + Reading track + No geometry for this track + It was saved without coordinates. Rebuild it in the track builder to see the map and record sectors. + Sectors + %1$d marked + None marked + Clear sectors + Session setup + Not selected + Choose track + Choose car + No cars in the garage yet + Start session + Arm timing and go green + Pick a track first + Pick a car first + Track set + Track needed + Car set + Car needed + Unknown + Clear + Mainly clear + Partly cloudy + Overcast + Fog + Drizzle + Freezing drizzle + Light rain + Rain + Heavy rain + Freezing rain + Light snow + Snow + Heavy snow + Snow grains + Rain showers + Snow showers + Thunderstorm + Thunderstorm with hail + Loading session + Session not found + Improving ↑ + Fading ↓ + Consistent → + Session + Key metrics + Best lap + Average + Slowest + Predicted best + Theoretical lap + Time on the table + Your best sector from every lap, combined. Based on %1$d of %2$d laps with a full set of splits. + Session stats + Total time + Laps + Consistency (σ) + Best to slowest + Trend + Conditions + Weather + Surface + Wet + Dry + Air temperature + Humidity + Wind + Pressure + No timed laps in this session + Restore this session + Void this session + Counts toward your bests again + Kept and stamped, but stops counting toward your bests + Best + Lap %1$d + GPS gap + Top %1$s %2$s + No speed data + Reference + Speed + Line + Lap not found + Lap %1$d · %2$s + Compare · Lap %1$d + Remove compare + Hide stats + Lap stats + Comparing + Compare + Close + Lap time + Avg speed + GPS points + Sector %1$d + Delta + Compare with + Loading lap + Run + Duration + Distance + Air temp + Max accel + Net elev. + Climb ↑ + Descent ↓ + ¼ mile + Trap speed + GPS trace + Speed + Chart + Map + No GPS data + Loading trace + No GPS trace recorded for this run + Calculating + Delta to best + Delta to best - live + Delta to track best + Delta to track best - live + Delta to session best + Delta to session best - live + Delta to session best - no track best yet + Could not load the track or start the session + Runs + Last + Sectors logged + End session? + Timing stops and the session is written to the archive. You can void it on the next screen if it should not count. + End session + Keep driving + Session failed + Could not start timing + Back to panel + Lap + Run + Current lap + Current run + Live + Compare to + Session best + Track best + Stint + Last laps + Last runs + Gap to best + No laps yet + No runs yet + Quarter mile + Best split + Stop recording? + The run is written to the archive. You can void it on the next screen if it should not count. + Stop + Keep recording + Run %1$d + GPS locked + Data + Shown + Hidden + Select a car + 1/4 MILE + 1/2 MILE + %1$s · seconds + Live speed · %1$s + Recording + Idle + 1/4 mile + 1/4 trap %1$s + 1/2 mile + Speed profile + Speed appears here once you start a run + Select a vehicle + Start drag + Every run is recorded against a car + Arm the timer and launch + Speed · %1$s + Max + + Recording · %d run + Recording · %d runs + + The run could not be written to the database + Session recording + Shown while a session is being timed, so it keeps running with the screen off. + Recording session + Timing continues with the screen off. + None selected + ESP32 (WiFi) Mode + ESP32 (Bluetooth) Mode + Phone GPS Mode + Change + Ground speed · %1$s + Source + Phone GPS + Link + Offline + Fix + Locked + Searching + Retry link + Change source + Reported: %1$s + Data stream + Remote IP (test) + Remote IP + Paired device + Latitude + Longitude + Altitude + Refresh + %1$d Hz (pending) + Raw packet + Awaiting data stream… + Linked, no satellite fix + The module is connected and talking. It has not locked onto satellites yet - that needs an open view of the sky and can take a minute from cold. + Nothing listening at %1$s:%2$d + The phone reached that address but nothing answered on port %1$d. The ESP32 is powered but its firmware may not be running - reflash from the TrackPro_ESP repo. + No route to %1$s + The phone could not reach the module at all. Join the ESP32\'s Wi-Fi network in Android settings - it does not route over mobile data. + Not connected to %1$s:%2$d + Check the ESP32 is powered, then that this phone is joined to its Wi-Fi network rather than your home or phone network. + Serial link not open + Check the ESP32 is powered and still paired in Android Bluetooth settings. A module that was unpaired or reset has to be paired again before it appears here. + Phone GPS not reporting + Allow location access for TrackPro and go outdoors with a clear view of the sky. Phone GPS also samples far slower than the ESP rig, so times will be coarser. + Track Builder + Start + Start / finish + Finish line + Waypoint %1$d + Track configuration + Unnamed track + Edit + Mark sector %1$d + Snapping to roads... + ! Last leg couldn\'t be routed - straight line used + Live GPS + Manual Map + Stop recording + Start GPS recording + Recording · walk the circuit + Undo last + Save track + Track Details + Track Name + Country + Timing Mode + Done + Not synced yet + Offline at %1$s · will retry + %1$d sent + %1$d restored + %1$d withdrawn + %1$d up to date + %1$d failed + Everything up to date + Main car + Personal bests + Account + App + Profile photo + Racing on this phone + Driver since %1$s + TrackBoard driver + Sign in to back up your garage and see where you place on every track. + Laps timed since %1$s + Laps timed + Sessions + Km lapped + Every lap is filed against the car that set it + from your account + No personal bests yet + Your best on each track lands here after a timed lap + Places appear when you sign in and share your laps. + Export saved + Edit profile + Name, photo, country, bio + Share laps on leaderboards + Your laps are on the leaderboards + Your laps stay private + Shared: your name, lap and sector times, session date, car make, model and year, and which GPS timed it. Only sessions on premade or published tracks. Turning it off takes them back down. + Sync + Syncing… + Sync now + Export your data + Everything the server holds, as one JSON file + Sign out + Your records stay on this phone + Delete account + Removes it from the server for good + Delete your TrackBoard account? + Your profile, backed-up cars, photos and every lap you shared are removed from the server, and you leave every leaderboard. The cars, tracks and sessions on this phone are not touched. + DELETE + Type %1$s to confirm + Your password + Deleting… + + of %d driver + of %d drivers + + Optional account + Your garage, tracks and shared sessions, backed up and restored on a new phone + Your place on every track\'s leaderboard, next to your personal best + Optional. Recording never needs an account or a network + Server · %1$s + not set + Changing your photo needs a connection. %1$s + Removing your photo needs a connection. %1$s + Saving needs a connection. %1$s + Uploading… + Choose photo + Remove photo + Details + Name on leaderboards + About you + Saving… + Save + Online · TrackBoard + Signed in as %1$s. Your account, lap sharing and sync are on the Profile tab. + Optional. Sign in from the Profile tab to back up your garage and post laps to leaderboards. Recording never needs an account or a network. + Server + e.g. http://10.0.2.2:5000 + Create account + Email + Password (12+ characters) + Password + Set a server first. + Working… + Shared track · every TrackPro driver + Published by you + Private · publish it to open a leaderboard + Private · sign in under Settings to publish + Leaderboard + Unpublish + Publish + Open + TrackBoard could not be reached. + TrackBoard refused the request. + No laps posted yet + Laps appear here once drivers who share their laps have driven this track. + Fetching + Your best + Lap times are posted by drivers and not verified. Phone GPS is less precise than an ESP32 module. + %1$s · YOU + Race your friends\' laps + Track days on TrackBoard put every driver\'s laps on one live board. Sign in, then join with the code the host gives you. + Offline. Showing the events this phone already knows. + This TrackBoard server doesn\'t have events yet. + Join an event + Your events + Fetching your events… + None yet. When a host gives you a code, enter it above. + Hosting a track day? Create the event on the TrackBoard website and hand out its code. + Live now + of %1$d + No lap from you yet. Drive a session on %1$s; each lap posts as you cross the line. + Get %1$s on this phone first, so your laps are timed on the same gates as everyone else\'s. + Open live board + Enter the 6-character code from the host. + No event has that code. Check it with the host. + Joining needs a connection. %1$s + Join code + Find + %1$s · %2$s · host %3$s + Your run group + Joining shares every lap you drive on %1$s during the event with its board, private sessions included. + Joining… + Update my group + Join event + This event has finished. + This event has finished; you can still open its board. + Host + Upcoming + Finished + %1$d h %2$d min left + %1$d min left + This event no longer exists. + Event + Overall + Nobody has joined yet + Nobody in this group yet + Drivers join with the host\'s code in the Events tab. Their laps appear here as they drive. + Fetching the board + %1$s is on this phone. Pick it when you start a session. + Getting the track needs a connection. + Every lap a joined driver drives on this track during the event counts, private sessions included. Times are posted by drivers and not verified. + Leave and remove my laps from this board + Could not leave the event. + Leave event + You\'re fastest + You, of %1$d + %1$s to P1 + On track + %1$s · you + No lap + Last lap PB + Get the event\'s track + %1$s isn\'t on this phone yet. Download it so your laps are timed on the same gates as everyone else\'s. + Downloading… + Get %1$s + Units, contrast, GPS source, server + Rig connection + ESP32 link and diagnostics + No track geometry \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 36e3565..9eada6c 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,5 +1,12 @@ - + diff --git a/app/src/test/java/com/example/trackpro/SessionFiltersTest.kt b/app/src/test/java/com/example/trackpro/SessionFiltersTest.kt new file mode 100644 index 0000000..5e8e9ee --- /dev/null +++ b/app/src/test/java/com/example/trackpro/SessionFiltersTest.kt @@ -0,0 +1,165 @@ +package com.example.trackpro + +import com.example.trackpro.screens.history.SessionFilter +import com.example.trackpro.screens.history.SessionKey +import com.example.trackpro.screens.history.SessionPeriod +import com.example.trackpro.screens.history.SessionSort +import com.example.trackpro.screens.history.applySessionFilter +import com.example.trackpro.screens.history.groupSessions +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.ZoneId +import java.time.ZonedDateTime + +/** Narrowing and ordering the History list. */ +class SessionFiltersTest { + + private val zone = ZoneId.of("Europe/Budapest") + /** Mid-afternoon on 30 September 2026. */ + private val now = ZonedDateTime.of(2026, 9, 30, 15, 0, 0, 0, zone) + + private fun at(year: Int, month: Int, day: Int, hour: Int = 12) = + ZonedDateTime.of(year, month, day, hour, 0, 0, 0, zone).toInstant().toEpochMilli() + + private data class S(val name: String, val key: SessionKey) + + private fun s(name: String, car: Long, track: Long?, start: Long, bestLapMs: Long? = null) = + S(name, SessionKey(car, track, start, bestLapMs)) + + private val sessions = listOf( + s("mx5-hungaroring-sep", car = 1, track = 10, start = at(2026, 9, 28), bestLapMs = 125_000), + s("mx5-pannonia-sep", car = 1, track = 20, start = at(2026, 9, 12), bestLapMs = 118_000), + s("gt86-hungaroring-aug", car = 2, track = 10, start = at(2026, 8, 3), bestLapMs = 121_000), + s("gt86-hungaroring-2025", car = 2, track = 10, start = at(2025, 6, 1), bestLapMs = 130_000), + s("mx5-hungaroring-nolaps", car = 1, track = 10, start = at(2026, 9, 29), bestLapMs = null), + ) + + private fun List.run(filter: SessionFilter) = + applySessionFilter(filter, now) { it.key }.map { it.name } + + // ───────────────────────────────────────────── + // Filtering + // ───────────────────────────────────────────── + + @Test + fun `no filter keeps everything, newest first`() { + assertEquals( + listOf( + "mx5-hungaroring-nolaps", "mx5-hungaroring-sep", "mx5-pannonia-sep", + "gt86-hungaroring-aug", "gt86-hungaroring-2025" + ), + sessions.run(SessionFilter()) + ) + } + + @Test + fun `car and track combine`() { + assertEquals( + listOf("gt86-hungaroring-aug", "gt86-hungaroring-2025"), + sessions.run(SessionFilter(vehicleId = 2, trackId = 10)) + ) + } + + @Test + fun `last 7 days runs from midnight six days ago`() { + // 24 Sep 00:00 onwards: the 28th and 29th, not the 12th. + assertEquals( + listOf("mx5-hungaroring-nolaps", "mx5-hungaroring-sep"), + sessions.run(SessionFilter(period = SessionPeriod.LAST_7_DAYS)) + ) + } + + @Test + fun `last 7 days includes the early morning of its first day`() { + val early = s("dawn", car = 1, track = 10, start = at(2026, 9, 24, hour = 0) + 60_000) + val justBefore = s("late", car = 1, track = 10, start = at(2026, 9, 23, hour = 23)) + + assertEquals(listOf("dawn"), listOf(early, justBefore).run(SessionFilter(period = SessionPeriod.LAST_7_DAYS))) + } + + @Test + fun `this year starts on the first of January`() { + assertEquals( + 4, + sessions.run(SessionFilter(period = SessionPeriod.THIS_YEAR)).size + ) + assertNull(SessionPeriod.ALL.startsFrom(now)) + } + + // ───────────────────────────────────────────── + // Ordering + // ───────────────────────────────────────────── + + @Test + fun `oldest first reverses the order`() { + assertEquals( + "gt86-hungaroring-2025", + sessions.run(SessionFilter(sort = SessionSort.OLDEST)).first() + ) + } + + @Test + fun `fastest puts the quickest best lap first and lapless sessions last`() { + assertEquals( + listOf( + "mx5-pannonia-sep", "gt86-hungaroring-aug", "mx5-hungaroring-sep", + "gt86-hungaroring-2025", "mx5-hungaroring-nolaps" + ), + sessions.run(SessionFilter(sort = SessionSort.FASTEST)) + ) + } + + // ───────────────────────────────────────────── + // Grouping + // ───────────────────────────────────────────── + + private fun groupsBy(sort: SessionSort): List>> = + sessions.applySessionFilter(SessionFilter(sort = sort), now) { it.key } + .groupSessions(sort, { it.key }) { it.key.trackId } + .map { (track, members) -> track to members.map { it.name } } + + @Test + fun `groups keep the chosen order and come most recent first`() { + val groups = groupsBy(SessionSort.NEWEST) + + assertEquals(listOf(10L, 20L), groups.map { it.first }) + assertEquals( + listOf("mx5-hungaroring-nolaps", "mx5-hungaroring-sep", "gt86-hungaroring-aug", "gt86-hungaroring-2025"), + groups.first().second + ) + } + + @Test + fun `fastest orders inside each track but not the tracks by lap time`() { + // Pannonia's 1:58 is quicker than anything at the Hungaroring, but a lap of a shorter + // circuit is not a better lap: the tracks stay in order of most recent use. + val groups = groupsBy(SessionSort.FASTEST) + + assertEquals(listOf(10L, 20L), groups.map { it.first }) + assertEquals( + listOf("gt86-hungaroring-aug", "mx5-hungaroring-sep", "gt86-hungaroring-2025", "mx5-hungaroring-nolaps"), + groups.first().second + ) + } + + // ───────────────────────────────────────────── + // The filter itself + // ───────────────────────────────────────────── + + @Test + fun `order alone does not count as narrowing`() { + assertFalse(SessionFilter(sort = SessionSort.OLDEST).narrows) + assertTrue(SessionFilter(vehicleId = 1).narrows) + assertTrue(SessionFilter(period = SessionPeriod.LAST_30_DAYS).narrows) + } + + @Test + fun `clearing keeps the order`() { + val cleared = SessionFilter(vehicleId = 1, trackId = 10, sort = SessionSort.FASTEST).cleared() + + assertEquals(SessionFilter(sort = SessionSort.FASTEST), cleared) + } +} diff --git a/app/src/test/java/com/example/trackpro/online/EventSyncTest.kt b/app/src/test/java/com/example/trackpro/online/EventSyncTest.kt new file mode 100644 index 0000000..86ab13b --- /dev/null +++ b/app/src/test/java/com/example/trackpro/online/EventSyncTest.kt @@ -0,0 +1,146 @@ +package com.example.trackpro.online + +import com.example.trackpro.dataClasses.LapTimeData +import com.example.trackpro.dataClasses.RemoteLink +import com.example.trackpro.dataClasses.RemoteLink.Companion.KIND_SESSION +import com.example.trackpro.dataClasses.RemoteLink.Companion.KIND_SHARED_TRACK +import com.example.trackpro.dataClasses.SessionData +import com.example.trackpro.dataClasses.TrackCoordinatesData +import com.example.trackpro.dataClasses.TrackMainData +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** How joined track-day events change what sync sends. */ +class EventSyncTest { + + private val api = FakeApi() + private val dao = FakeSyncDao() + private var sharing = false + private val premadeIds = mutableMapOf() + private val events = mutableListOf() + private var nextId = 0 + + private val start = 1_700_000_000_000L + + private fun engine() = SyncEngine( + api = api, + auth = AuthRepository(api, signedInStore()), + dao = dao, + premade = PremadeTracks { track, _ -> premadeIds[track.trackId] }, + sharingEnabled = { sharing }, + appVersion = "1.1", + newId = { "id-${++nextId}" }, + clock = { 1_000L }, + events = { events }, + ) + + private fun run() = runBlocking { engine().run() } + + private fun track(id: Long, name: String) { + dao.tracks += TrackMainData(trackId = id, trackName = name, totalLength = 1.05, country = "HU", type = "Circuit") + dao.points += listOf( + TrackCoordinatesData(id = id * 100, trackId = id, latitude = 47.0, longitude = 19.0, altitude = null, isStartPoint = true), + TrackCoordinatesData(id = id * 100 + 1, trackId = id, latitude = 47.001, longitude = 19.0, altitude = null), + ) + } + + private fun premadeKakucs(id: Long = 4) { + track(id, "Kakucs ring") + premadeIds[id] = "premade-kakucs" + } + + private fun joined(trackId: String = "premade-kakucs", from: Long = start - 3_600_000, to: Long = start + 3_600_000) { + events += JoinedEvent("event-1", "Club day", trackId, "Kakucs ring", from, to) + } + + private fun session(id: Long = 7, trackId: Long = 4, startTime: Long = start, running: Boolean = false, times: List = listOf("00:41.91")) { + dao.sessions.removeAll { it.id == id } + dao.sessions += SessionData( + id = id, startTime = startTime, endTime = if (running) null else startTime + 600_000, eventType = "Track day", + vehicleId = null, trackId = trackId, voided = false, gpsSource = "WIFI", + ) + dao.laps.removeAll { it.sessionid == id } + times.forEachIndexed { i, t -> dao.laps += LapTimeData(id = id * 10 + i, sessionid = id, lapnumber = i + 1, laptime = t) } + } + + @Test + fun `with sharing off, a session in a joined event still goes up, as private`() { + premadeKakucs(); joined(); session() + + val report = run() + + assertEquals(1, report.uploaded) + assertEquals(ApiSessionVisibility.Private, api.sessions.values.single().visibility) + } + + @Test + fun `with sharing on, an event session is ranked as usual`() { + sharing = true + premadeKakucs(); joined(); session() + + run() + + assertEquals(ApiSessionVisibility.Ranked, api.sessions.values.single().visibility) + } + + @Test + fun `a session outside the event window stays on the phone when sharing is off`() { + premadeKakucs(); joined(); session(startTime = start + 2 * 3_600_000) + + run() + + assertTrue(api.sessions.isEmpty()) + } + + @Test + fun `a running event session goes up after a lap and a full sync does not withdraw it`() { + premadeKakucs(); joined(); session(running = true) + + val pushed = runBlocking { engine().pushLive(7) } + assertTrue(pushed) + val remoteId = dao.links[KIND_SESSION to 7L]!!.remoteId + assertEquals(null, api.sessions.getValue(remoteId).endedAt) + + // A periodic sync mid-session: not finished, so not planned, but it must stay up. + val report = run() + + assertEquals(0, report.withdrawn) + assertTrue(remoteId in api.sessions) + + // The next lap goes up to the same record. + session(running = true, times = listOf("00:41.91", "00:41.20")) + runBlocking { engine().pushLive(7) } + assertEquals(2, api.sessions.getValue(remoteId).laps.size) + } + + @Test + fun `pushLive ignores a session that is not in a joined event`() { + premadeKakucs(); session(running = true) + + val pushed = runBlocking { engine().pushLive(7) } + + assertFalse(pushed) + assertTrue(api.sessions.isEmpty()) + } + + @Test + fun `a track downloaded for an event is timed against, never uploaded as the driver's own`() { + track(9, "Host's own track") + dao.putLinkBlocking(RemoteLink(KIND_SHARED_TRACK, 9, "hosts-track", "shared", 1L)) + api.foreignTracks += "hosts-track" + joined(trackId = "hosts-track") + session(trackId = 9) + + run() + + assertTrue(api.puts("track").isEmpty()) + assertEquals("hosts-track", api.sessions.values.single().trackId) + assertNotNull(dao.links[KIND_SESSION to 7L]) + } +} + +private fun FakeSyncDao.putLinkBlocking(link: RemoteLink) = runBlocking { putLink(link) } diff --git a/app/src/test/java/com/example/trackpro/online/Fakes.kt b/app/src/test/java/com/example/trackpro/online/Fakes.kt index 866e6ee..622656a 100644 --- a/app/src/test/java/com/example/trackpro/online/Fakes.kt +++ b/app/src/test/java/com/example/trackpro/online/Fakes.kt @@ -42,6 +42,7 @@ open class FakeApi : TrackBoardApi { val foreignTracks = mutableSetOf() var onPutSession: (SessionWrite) -> Unit = {} var onPutVehicle: (VehicleWrite) -> Unit = {} + var onPutTrack: (String) -> Unit = {} var onDeleteTrack: (String) -> Unit = {} var refresh: suspend (String) -> AuthResponse = { authResponse("access-2", "refresh-2") } @@ -61,6 +62,7 @@ open class FakeApi : TrackBoardApi { override suspend fun putTrack(accessToken: String, id: String, body: TrackWrite): Boolean { calls += "PUT track $id ${body.visibility}" + onPutTrack(id) if (id in foreignTracks) throw ApiException(403, "That belongs to another account.") return tracks.put(id, body) == null } @@ -93,6 +95,43 @@ open class FakeApi : TrackBoardApi { override suspend fun getLeaderboard(accessToken: String?, trackId: String, limit: Int): Leaderboard? = null + override suspend fun getTrackDetail(accessToken: String, id: String): TrackDetail? { + calls += "GET track detail $id" + val t = tracks[id] ?: return null + return TrackDetail(id, t.name, t.country, t.type, t.visibility, t.lengthMeters, t.points) + } + + /** Everything in [tracks] but the foreign ones is this driver's, all on one page. */ + override suspend fun listMyTracks(accessToken: String, page: Int, pageSize: Int): TrackPage { + calls += "LIST tracks" + val mine = tracks.filterKeys { it !in foreignTracks }.map { (id, t) -> TrackSummary(id, t.name, t.visibility) } + return TrackPage(if (page == 1) mine else emptyList(), page, pageSize, mine.size.toLong()) + } + + override suspend fun listSessions(accessToken: String, page: Int, pageSize: Int): SessionPage { + calls += "LIST sessions" + val all = sessions.map { (id, s) -> SessionSummary(id, s.startedAt, s.trackId, s.vehicleId, s.laps.size) } + return SessionPage(if (page == 1) all else emptyList(), page, pageSize, all.size.toLong()) + } + + /** Makes every car lookup answer "not found", as a server without the car would. */ + var hideVehicles = false + + override suspend fun getVehicle(accessToken: String, id: String): VehicleResponse? { + calls += "GET vehicle $id" + val v = vehicles[id]?.takeUnless { hideVehicles } ?: return null + return VehicleResponse( + id, v.manufacturer, v.model, v.year, v.engineType, v.horsepower, v.torque, v.weight, v.topSpeed, + v.acceleration, v.drivetrain, v.fuelType, v.tireType, v.fuelCapacity, v.transmission, v.suspensionType, + ) + } + + override suspend fun getSession(accessToken: String, id: String): SessionDetail? { + calls += "GET session $id" + val s = sessions[id] ?: return null + return SessionDetail(id, s.name, s.startedAt, s.endedAt, s.trackId, s.vehicleId, s.gpsSource, s.voided, s.weather, s.laps) + } + fun puts(kind: String) = calls.filter { it.startsWith("PUT $kind") } } @@ -120,6 +159,7 @@ class FakeSyncDao : SyncDao { override suspend fun getFinishedTrackSessions() = sessions.filter { it.trackId != null && it.trackId != -1L && it.endTime != null } override suspend fun getSession(id: Long) = sessions.find { it.id == id } + override suspend fun getRunningSessionIds() = sessions.filter { it.endTime == null }.map { it.id } override suspend fun getLaps(sessionId: Long) = laps.filter { it.sessionid == sessionId }.sortedBy { it.lapnumber } override suspend fun getSectors(lapIds: List) = sectors.filter { it.lapid in lapIds } override suspend fun getTrack(trackId: Long) = tracks.find { it.trackId == trackId } @@ -137,4 +177,42 @@ class FakeSyncDao : SyncDao { vehicles.replaceAll { if (it.vehicleId == vehicle.vehicleId) vehicle else it } } override suspend fun deleteAllLinks() { links.clear() } + + override suspend fun getTracks() = tracks.sortedBy { it.trackId } + override suspend fun insertTrack(track: TrackMainData): Long { + val id = (tracks.maxOfOrNull { it.trackId } ?: 0L) + 1 + tracks += track.copy(trackId = id) + return id + } + override suspend fun insertTrackPoints(points: List) { + var next = (this.points.maxOfOrNull { it.id } ?: 0L) + 1 + points.forEach { this.points += it.copy(id = next++) } + } + override suspend fun insertSession(session: SessionData): Long { + val id = (sessions.maxOfOrNull { it.id } ?: 0L) + 1 + sessions += session.copy(id = id) + return id + } + override suspend fun insertLap(lap: LapTimeData): Long { + val id = (laps.maxOfOrNull { it.id } ?: 0L) + 1 + laps += lap.copy(id = id) + return id + } + override suspend fun insertSectors(sectors: List) { + var next = (this.sectors.maxOfOrNull { it.id } ?: 0L) + 1 + sectors.forEach { this.sectors += it.copy(id = next++) } + } + + /** Sessions whose laps have GPS points; the fake keeps no points, only this flag. */ + val withGpsTrace = mutableSetOf() + override suspend fun hasGpsTrace(sessionId: Long) = sessionId in withGpsTrace + override suspend fun setSessionVehicle(sessionId: Long, vehicleId: Long) { + sessions.replaceAll { if (it.id == sessionId) it.copy(vehicleId = vehicleId) else it } + } + override suspend fun deleteSession(sessionId: Long) { + val lapIds = laps.filter { it.sessionid == sessionId }.map { it.id }.toSet() + sectors.removeAll { it.lapid in lapIds } + laps.removeAll { it.sessionid == sessionId } + sessions.removeAll { it.id == sessionId } + } } diff --git a/app/src/test/java/com/example/trackpro/online/GarageSyncTest.kt b/app/src/test/java/com/example/trackpro/online/GarageSyncTest.kt index 43f7aaf..0fc5978 100644 --- a/app/src/test/java/com/example/trackpro/online/GarageSyncTest.kt +++ b/app/src/test/java/com/example/trackpro/online/GarageSyncTest.kt @@ -26,7 +26,7 @@ class FakeAccountApi : TrackBoardAccountApi { override suspend fun updateProfile(accessToken: String, body: UpdateProfileRequest) = error("unused") override suspend fun getStats(accessToken: String) = error("unused") override suspend fun exportAccount(accessToken: String) = error("unused") - override suspend fun deleteAccount(accessToken: String) = error("unused") + override suspend fun deleteAccount(accessToken: String, password: String) = error("unused") override suspend fun setAvatar(accessToken: String, path: String?) = error("unused") override suspend fun listVehicles(accessToken: String, page: Int, pageSize: Int): VehiclePage { diff --git a/app/src/test/java/com/example/trackpro/online/MessageTextTest.kt b/app/src/test/java/com/example/trackpro/online/MessageTextTest.kt new file mode 100644 index 0000000..41675bb --- /dev/null +++ b/app/src/test/java/com/example/trackpro/online/MessageTextTest.kt @@ -0,0 +1,60 @@ +package com.example.trackpro.online + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import java.util.Locale + +class MessageTextTest { + + private lateinit var saved: Locale + + @Before fun remember() { saved = Locale.getDefault() } + @After fun restore() { Locale.setDefault(saved) } + + @Test + fun `english stays exactly as it came`() { + Locale.setDefault(Locale.ENGLISH) + assertEquals("Could not reach the TrackBoard server.", MessageText.localize("Could not reach the TrackBoard server.")) + } + + @Test + fun `a known message is translated`() { + Locale.setDefault(Locale("hu", "HU")) + assertEquals("Nem sikerült elérni a TrackBoard szervert.", MessageText.localize("Could not reach the TrackBoard server.")) + } + + @Test + fun `captured parts are carried over and translated again`() { + Locale.setDefault(Locale("hu", "HU")) + assertEquals( + "A(z) Lexus IS200 fotóját nem sikerült szinkronizálni: Nem sikerült elérni a fotótárat.", + MessageText.localize("The photo of Lexus IS200 could not be synced: Could not reach photo storage.") + ) + } + + @Test + fun `the specific pattern wins over the general one`() { + Locale.setDefault(Locale("hu", "HU")) + assertEquals("A szerver nem fogadta el.", MessageText.localize("The server rejected it.")) + assertEquals("A szerver nem fogadta el: email.", MessageText.localize("The server rejected email.")) + } + + @Test + fun `every line of a multi-line message is translated on its own`() { + Locale.setDefault(Locale("hu", "HU")) + assertEquals( + "Hibás jelszó.\nNem vagy bejelentkezve.", + MessageText.localize("The password is incorrect.\nNot signed in.") + ) + } + + @Test + fun `an unknown message is shown as it came`() { + Locale.setDefault(Locale("hu", "HU")) + assertEquals("Something nobody translated.", MessageText.localize("Something nobody translated.")) + assertNull(MessageText.localize(null)) + } +} diff --git a/app/src/test/java/com/example/trackpro/online/SyncEngineTest.kt b/app/src/test/java/com/example/trackpro/online/SyncEngineTest.kt index 0b40708..0a8c56b 100644 --- a/app/src/test/java/com/example/trackpro/online/SyncEngineTest.kt +++ b/app/src/test/java/com/example/trackpro/online/SyncEngineTest.kt @@ -1,10 +1,12 @@ package com.example.trackpro.online import com.example.trackpro.dataClasses.LapTimeData +import com.example.trackpro.dataClasses.RemoteLink import com.example.trackpro.dataClasses.RemoteLink.Companion.KIND_PREMADE_TRACK import com.example.trackpro.dataClasses.RemoteLink.Companion.KIND_SESSION import com.example.trackpro.dataClasses.RemoteLink.Companion.KIND_TRACK import com.example.trackpro.dataClasses.RemoteLink.Companion.KIND_VEHICLE +import com.example.trackpro.dataClasses.SectorTimeData import com.example.trackpro.dataClasses.SessionData import com.example.trackpro.dataClasses.TrackCoordinatesData import com.example.trackpro.dataClasses.TrackMainData @@ -25,7 +27,7 @@ class SyncEngineTest { private val premadeIds = mutableMapOf() private var nextId = 0 - private fun engine(store: TokenStore = signedInStore()) = SyncEngine( + private fun engine(store: TokenStore = signedInStore(), dao: FakeSyncDao = this.dao) = SyncEngine( api = api, auth = AuthRepository(api, store), dao = dao, @@ -38,6 +40,11 @@ class SyncEngineTest { private fun run(store: TokenStore = signedInStore()) = runBlocking { engine(store).run() } + /** A second phone signed in to the same account: its own database, the same server. */ + private val otherPhone = FakeSyncDao() + + private fun runOtherPhone() = runBlocking { engine(dao = otherPhone).run() } + // ── Fixtures ── private fun track(id: Long, name: String) { @@ -182,6 +189,18 @@ class SyncEngineTest { assertEquals(0, report.failed) } + @Test + fun `a full account says so instead of calling the premade track unavailable`() { + premadeTrack(); session(trackId = 4) + val message = "You have reached the limit of 100 tracks. Delete one before adding another." + api.onPutTrack = { throw ApiException(409, message, "QuotaExceeded") } + + val report = run() + + assertEquals(1, report.failed) + assertEquals(message, report.problem) + } + @Test fun `a premade track is never re-uploaded, even after a local sector change`() { premadeTrack(); session(trackId = 4) @@ -237,16 +256,21 @@ class SyncEngineTest { } @Test - fun `a deleted track other drivers still use is simply let go`() { + fun `a deleted track other drivers still use is let go, and not restored`() { userTrack(); session() run() api.onDeleteTrack = { throw ApiException(409, "Other drivers have sessions on this track.", "TrackInUse") } dao.tracks.clear(); dao.points.clear(); dao.sessions.clear(); dao.laps.clear(); dao.published.clear() val report = run() + api.calls.clear() + val again = run() assertEquals(0, report.failed) - assertNull(dao.links[KIND_TRACK to 3L]) + assertEquals(GarageSync.DELETED_HERE, dao.links[KIND_TRACK to 3L]!!.uploadedHash) + assertTrue("the account still has it, but it stays deleted here", dao.tracks.isEmpty()) + assertEquals(0, again.downloaded) + assertTrue("and it is not asked to delete it again", api.calls.none { it.startsWith("DELETE") }) } // ── Failures ── @@ -300,4 +324,275 @@ class SyncEngineTest { assertNull(api.sessions.values.single().vehicleId) assertFalse(report.offline) } + + // ── Restoring onto another phone ── + + /** This phone posts a session with a car, sectors and weather on its own published track. */ + private fun postFromThisPhone() { + userTrack(); vehicle(); session(vehicleId = 1, times = listOf("01:30.00", "01:28.50")) + dao.sessions.replaceAll { it.copy(weatherTempC = 21.5, weatherCode = 3) } + dao.sectors += SectorTimeData(id = 1, lapid = 70, sectorIndex = 0, splitTimeMs = 41_000) + run() + } + + /** The other phone has the same car, backed up to the same account. */ + private fun sameCarOnOtherPhone() { + otherPhone.vehicles += dao.vehicles.single().copy(vehicleId = 5) + otherPhone.links[KIND_VEHICLE to 5L] = dao.links[KIND_VEHICLE to 1L]!!.copy(localId = 5) + } + + @Test + fun `a new phone gets the published track and shared sessions back`() { + postFromThisPhone() + sameCarOnOtherPhone() + + val report = runOtherPhone() + + val track = otherPhone.tracks.single() + assertEquals("My track", track.trackName) + assertEquals(2, otherPhone.points.count { it.trackId == track.trackId }) + assertTrue("still published, so its sessions stay on the leaderboard", track.trackId in otherPhone.published) + + val session = otherPhone.sessions.single() + assertEquals(track.trackId, session.trackId) + assertEquals(5L, session.vehicleId) + assertEquals("WIFI", session.gpsSource) + assertEquals(1_700_000_000_000, session.startTime) + assertEquals(1_700_000_600_000, session.endTime) + assertEquals(21.5, session.weatherTempC!!, 0.0) + assertEquals(listOf("01:30.00", "01:28.50"), otherPhone.laps.sortedBy { it.lapnumber }.map { it.laptime }) + assertEquals(41_000L, otherPhone.sectors.single().splitTimeMs) + + assertEquals(2, report.downloaded) + assertEquals(0, report.failed) + } + + @Test + fun `what was restored is not sent back`() { + postFromThisPhone() + sameCarOnOtherPhone() + runOtherPhone() + api.calls.clear() + + val report = runOtherPhone() + + assertTrue(api.calls.none { it.startsWith("PUT") || it.startsWith("DELETE") || it.startsWith("GET session") }) + assertEquals(1, report.unchanged) + assertEquals(0, report.downloaded) + } + + @Test + fun `a restored session edited on the new phone updates the same record`() { + postFromThisPhone() + sameCarOnOtherPhone() + runOtherPhone() + otherPhone.sessions.replaceAll { it.copy(voided = true) } + + val report = runOtherPhone() + + assertEquals(1, report.uploaded) + assertTrue(api.sessions.values.single().voided) + assertEquals( + "the car it was posted with stays with it", + dao.links[KIND_VEHICLE to 1L]!!.remoteId, + api.sessions.values.single().vehicleId + ) + } + + @Test + fun `a restored session brings its car when the new phone does not have it`() { + postFromThisPhone() + val remoteCar = dao.links[KIND_VEHICLE to 1L]!!.remoteId + + val report = runOtherPhone() + api.calls.clear() + runOtherPhone() + + val car = otherPhone.vehicles.single() + assertEquals("Porsche", car.manufacturer) + assertEquals(car.vehicleId, otherPhone.sessions.single().vehicleId) + assertEquals(remoteCar, otherPhone.links[KIND_VEHICLE to car.vehicleId]!!.remoteId) + assertEquals("track, car and session", 3, report.downloaded) + assertTrue("nothing goes back up", api.calls.none { it.startsWith("PUT") }) + } + + @Test + fun `a car deleted on the new phone comes back when a restored session was driven in it`() { + postFromThisPhone() + val remoteCar = dao.links[KIND_VEHICLE to 1L]!!.remoteId + // Deleted there once, while the account had to keep it for this session. + otherPhone.links[KIND_VEHICLE to 3L] = RemoteLink(KIND_VEHICLE, 3, remoteCar, GarageSync.DELETED_HERE, 1L) + + runOtherPhone() + + val car = otherPhone.vehicles.single() + assertEquals(car.vehicleId, otherPhone.sessions.single().vehicleId) + assertNull("no longer remembered as deleted", otherPhone.links[KIND_VEHICLE to 3L]) + assertEquals(remoteCar, otherPhone.links[KIND_VEHICLE to car.vehicleId]!!.remoteId) + } + + @Test + fun `a session restored without its car gets it back, without being sent again`() { + postFromThisPhone() + // What the first version of restore did: the car was not found, the session came alone. + api.hideVehicles = true + runOtherPhone() + assertNull(otherPhone.sessions.single().vehicleId) + api.hideVehicles = false + + runOtherPhone() + api.calls.clear() + val report = runOtherPhone() + + val car = otherPhone.vehicles.single() + assertEquals(car.vehicleId, otherPhone.sessions.single().vehicleId) + assertTrue(api.calls.none { it.startsWith("PUT") }) + assertEquals(1, report.unchanged) + } + + @Test + fun `a change made to a carless session still goes up, now with its car`() { + postFromThisPhone() + api.hideVehicles = true + runOtherPhone() + api.hideVehicles = false + otherPhone.sessions.replaceAll { it.copy(voided = true) } + + runOtherPhone() + + val online = api.sessions.values.single() + assertTrue(online.voided) + assertEquals(dao.links[KIND_VEHICLE to 1L]!!.remoteId, online.vehicleId) + } + + @Test + fun `with sharing off only tracks come back, and nothing is withdrawn`() { + postFromThisPhone() + sharing = false + + val report = runOtherPhone() + + assertEquals(1, otherPhone.tracks.size) + assertTrue(otherPhone.sessions.isEmpty()) + assertEquals(1, api.sessions.size) + assertEquals(1, report.downloaded) + } + + @Test + fun `a session on a premade track lands on the phone's own copy of it`() { + premadeTrack(); session(trackId = 4) + run() + // Every install seeds the premade tracks, under whatever local id. + otherPhone.tracks += dao.tracks.single().copy(trackId = 9) + otherPhone.points += dao.points.map { it.copy(id = it.id + 1000, trackId = 9) } + premadeIds[9] = "premade-hungaroring" + + runOtherPhone() + + assertEquals("the shared track is not copied as the driver's own", 1, otherPhone.tracks.size) + assertEquals(9L, otherPhone.sessions.single().trackId) + assertTrue(otherPhone.links.keys.none { it.first == KIND_TRACK }) + } + + @Test + fun `a session already on the phone is linked, not doubled`() { + postFromThisPhone() + // The same session and track, on a phone that lost its links. + otherPhone.tracks += dao.tracks; otherPhone.points += dao.points; otherPhone.published += dao.published + otherPhone.sessions += dao.sessions.map { it.copy(vehicleId = null) }; otherPhone.laps += dao.laps + + runOtherPhone() + + assertEquals(1, otherPhone.tracks.size) + assertEquals(1, otherPhone.sessions.size) + assertEquals(dao.links[KIND_SESSION to 7L]!!.remoteId, otherPhone.links[KIND_SESSION to 7L]!!.remoteId) + assertEquals(dao.links[KIND_TRACK to 3L]!!.remoteId, otherPhone.links[KIND_TRACK to 3L]!!.remoteId) + assertEquals("one record online, updated from this phone", 1, api.sessions.size) + } + + @Test + fun `a session on a track the new phone does not share stays online, untouched`() { + postFromThisPhone() + // Restored once, then the track unpublished on the new phone and its session gone. + runOtherPhone() + otherPhone.sessions.clear(); otherPhone.laps.clear(); otherPhone.sectors.clear() + otherPhone.links.keys.removeAll { it.first == KIND_SESSION } + otherPhone.published.clear() + + runOtherPhone() + + assertTrue(otherPhone.sessions.isEmpty()) + } + + @Test + fun `an account that cannot be read costs the restore, not the uploads`() { + val failing = object : FakeApi() { + override suspend fun listSessions(accessToken: String, page: Int, pageSize: Int): SessionPage = + throw ApiException(500, "The server had a problem.") + } + val engine = SyncEngine( + api = failing, auth = AuthRepository(failing, signedInStore()), dao = dao, + premade = { _, _ -> null }, sharingEnabled = { true }, appVersion = "1.1", + ) + userTrack(); session() + + val report = runBlocking { engine.run() } + + assertEquals(1, report.uploaded) + assertEquals(1, report.failed) + assertTrue(report.problem!!.contains("sessions")) + } + + // ── The account holding one session twice ── + + /** Posts session 7, then gives the account a second record of it, as another phone would. */ + private fun postTwice(): String { + userTrack(); session() + run() + val first = dao.links[KIND_SESSION to 7L]!!.remoteId + api.sessions["second-record"] = api.sessions.getValue(first) + return first + } + + @Test + fun `a session the account holds twice is not brought down a second time`() { + postTwice() + + val report = run() + + assertEquals(1, dao.sessions.size) + assertEquals(0, report.downloaded) + assertTrue("both records stay online", api.sessions.containsKey("second-record")) + } + + @Test + fun `a second copy an earlier restore made is removed again, here only`() { + postTwice() + // What the first version of restore did with the second record. + dao.sessions += dao.sessions.single().copy(id = 8) + dao.laps += dao.laps.map { it.copy(id = it.id + 100, sessionid = 8) } + dao.links[KIND_SESSION to 8L] = dao.links[KIND_SESSION to 7L]!!.copy(localId = 8, remoteId = "second-record") + api.calls.clear() + + run() + + assertEquals(listOf(7L), dao.sessions.map { it.id }) + assertTrue(dao.laps.none { it.sessionid == 8L }) + assertNull(dao.links[KIND_SESSION to 8L]) + assertTrue("nothing is withdrawn for it", api.calls.none { it.startsWith("DELETE") }) + assertEquals(2, api.sessions.size) + } + + @Test + fun `a same-start session with its own GPS trace is never taken for a copy`() { + postTwice() + dao.sessions += dao.sessions.single().copy(id = 8) + dao.laps += dao.laps.map { it.copy(id = it.id + 100, sessionid = 8) } + dao.links[KIND_SESSION to 8L] = dao.links[KIND_SESSION to 7L]!!.copy(localId = 8, remoteId = "second-record") + dao.withGpsTrace += 8L + + run() + + assertEquals(listOf(7L, 8L), dao.sessions.map { it.id }) + } } diff --git a/app/src/test/java/com/example/trackpro/online/TrackBoardApiTest.kt b/app/src/test/java/com/example/trackpro/online/TrackBoardApiTest.kt index 24a924f..e169dcc 100644 --- a/app/src/test/java/com/example/trackpro/online/TrackBoardApiTest.kt +++ b/app/src/test/java/com/example/trackpro/online/TrackBoardApiTest.kt @@ -123,6 +123,102 @@ class TrackBoardApiTest { assertNull(board.me) } + @Test + fun `a session reads back with its laps, ignoring the leaderboard fields`() = runBlocking { + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{"id":"s-1","name":"Time Attack","startedAt":"2026-09-28T10:00:00.25+00:00", + "endedAt":"2026-09-28T10:20:00+00:00","trackId":"t-1","trackName":"Kakucs","vehicleId":null, + "gpsSource":"Wifi","visibility":"Ranked","voided":false,"lapCount":1,"bestLapMs":41910, + "weather":{"tempC":18.5,"humidityPct":60,"precipitationMm":null,"weatherCode":2, + "windKph":null,"windDirDeg":null,"pressureHpa":null},"appVersion":"1.1", + "laps":[{"lapNumber":1,"timeMs":41910,"signalGap":false, + "sectors":[{"sectorIndex":0,"splitMs":20000}],"countsForLeaderboard":true,"leaderboardRank":3}], + "createdAt":"2026-09-28T10:21:00+00:00","updatedAt":"2026-09-28T10:21:00+00:00"}""" + ) + ) + + val session = api.getSession("t", "s-1")!! + + assertEquals("/api/v1/sessions/s-1", server.takeRequest().path) + assertEquals(ApiGpsSource.Wifi, session.gpsSource) + assertEquals(18.5, session.weather!!.tempC!!, 0.0) + assertEquals(20_000, session.laps.single().sectors.single().splitMs) + // .NET's offset form, which Instant.parse alone rejects before API 34. + assertEquals(1_790_589_600_250L, AccountRestore.parseInstant(session.startedAt)) + } + + @Test + fun `a session that is gone reads as none`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(404).setBody("""{"status":404}""")) + assertNull(api.getSession("t", "s-1")) + } + + @Test + fun `listings ask for the driver's own records, a page at a time`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(200).setBody("""{"items":[],"page":2,"pageSize":100,"totalCount":0}""")) + server.enqueue( + MockResponse().setResponseCode(200).setBody( + """{"items":[{"id":"s-1","name":"x","startedAt":"2026-09-28T10:00:00+00:00","trackId":"t-1", + "vehicleId":null,"gpsSource":"Wifi","visibility":"Ranked","voided":false,"lapCount":4,"bestLapMs":1}], + "page":1,"pageSize":100,"totalCount":1,"totalPages":1,"hasNext":false,"hasPrevious":false}""" + ) + ) + + api.listMyTracks("t", 2, 100) + val sessions = api.listSessions("t", 1, 100) + + assertEquals("/api/v1/tracks?mine=true&page=2&pageSize=100", server.takeRequest().path) + assertEquals("/api/v1/sessions?page=1&pageSize=100", server.takeRequest().path) + assertEquals(4, sessions.items.single().lapCount) + } + + @Test + fun `deleting the account sends the password in the body`() = runBlocking { + server.enqueue(MockResponse().setResponseCode(204)) + + api.deleteAccount("access-token", "correct horse battery") + + val request = server.takeRequest() + assertEquals("DELETE", request.method) + assertEquals("/api/v1/me", request.path) + assertEquals("Bearer access-token", request.getHeader("Authorization")) + assertEquals("""{"password":"correct horse battery"}""", request.body.readUtf8()) + } + + @Test + fun `a wrong password on delete is the server's own 403 message, not an expired session`() = runBlocking { + server.enqueue( + MockResponse().setResponseCode(403).setHeader("Content-Type", "application/problem+json").setBody( + """{"title":"Access denied","status":403,"detail":"The password is incorrect."}""" + ) + ) + try { + api.deleteAccount("t", "nope") + fail("expected ApiException") + } catch (e: ApiException) { + assertEquals(403, e.status) + assertEquals("The password is incorrect.", e.message) + } + } + + @Test + fun `a locked account says how long to wait`() = runBlocking { + server.enqueue( + MockResponse().setResponseCode(429).setHeader("Content-Type", "application/problem+json").setBody( + """{"title":"Too many requests","status":429,"detail":"Too many failed attempts. Try again in 15 minutes.","code":"AccountLocked"}""" + ) + ) + try { + api.deleteAccount("t", "nope") + fail("expected ApiException") + } catch (e: ApiException) { + assertEquals(429, e.status) + assertEquals("AccountLocked", e.code) + assertEquals("Too many failed attempts. Try again in 15 minutes.", e.message) + } + } + @Test fun `deleting something already gone succeeds`() = runBlocking { server.enqueue(MockResponse().setResponseCode(404)) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 07e42e5..367e9bf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,6 @@ ksp = "1.9.22-1.0.17" ktx = "2.5.1" kotlin = "1.9.22" material3Android = "1.3.1" -generativeai = "0.9.0" uiTestAndroid = "1.5.1" graphicsCore = "1.0.3" workRuntimeKtx = "2.10.2" @@ -15,7 +14,6 @@ playServicesLocation = "21.3.0" [libraries] androidx-ktx = {group = "androidx.room", name = "room-ktx", version.ref = "ktx"} androidx-material3-android = { group = "androidx.compose.material3", name = "material3-android", version.ref = "material3Android" } -generativeai = { group = "com.google.ai.client.generativeai", name = "generativeai", version.ref = "generativeai" } androidx-ui-test-android = { group = "androidx.compose.ui", name = "ui-test-android", version.ref = "uiTestAndroid" } androidx-graphics-core = { group = "androidx.graphics", name = "graphics-core", version.ref = "graphicsCore" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "workRuntimeKtx" } diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..e7f0d42 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "mobile-app-ui-design": { + "source": "ceorkm/mobile-app-ui-design", + "sourceType": "github", + "skillPath": "SKILL.md", + "computedHash": "0f039cf367e8f72ca25d27e503e8a68f26210c6bbe31ed51c86d7263ecfe5f74" + } + } +}