Add Stadium Progress system and themed loading spinners
Stadium Progress & Achievements: - Add StadiumVisit and Achievement SwiftData models - Create Progress tab with interactive map view - Implement photo-based visit import with GPS/date matching - Add achievement badges (count-based, regional, journey) - Create shareable progress cards for social media - Add canonical data infrastructure (stadium identities, team aliases) - Implement score resolution from free APIs (MLB, NBA, NHL stats) UI Improvements: - Add ThemedSpinner and ThemedSpinnerCompact components - Replace all ProgressView() with themed spinners throughout app - Fix sport selection state not persisting when navigating away Bug Fixes: - Fix Coast to Coast trips showing only 1 city (validation issue) - Fix stadium progress showing 0/0 (filtering issue) - Remove "Stadium Quest" title from progress view 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,40 @@ CONTAINER = "iCloud.com.sportstime.app"
|
||||
HOST = "https://api.apple-cloudkit.com"
|
||||
BATCH_SIZE = 200
|
||||
|
||||
# Hardcoded credentials
|
||||
DEFAULT_KEY_ID = "152be0715e0276e31aaea5cbfe79dc872f298861a55c70fae14e5fe3e026cff9"
|
||||
DEFAULT_KEY_FILE = "eckey.pem"
|
||||
|
||||
|
||||
def show_menu():
|
||||
"""Show interactive menu and return selected action."""
|
||||
print("\n" + "="*50)
|
||||
print("CloudKit Import - Select Action")
|
||||
print("="*50)
|
||||
print("\n 1. Import all (stadiums, teams, games, league structure, team aliases)")
|
||||
print(" 2. Stadiums only")
|
||||
print(" 3. Games only")
|
||||
print(" 4. League structure only")
|
||||
print(" 5. Team aliases only")
|
||||
print(" 6. Canonical only (league structure + team aliases)")
|
||||
print(" 7. Delete all then import")
|
||||
print(" 8. Delete only (no import)")
|
||||
print(" 9. Dry run (preview only)")
|
||||
print(" 0. Exit")
|
||||
print()
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input("Enter choice [1-9, 0 to exit]: ").strip()
|
||||
if choice == '0':
|
||||
return None
|
||||
if choice in ['1', '2', '3', '4', '5', '6', '7', '8', '9']:
|
||||
return int(choice)
|
||||
print("Invalid choice. Please enter 1-9 or 0.")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nExiting.")
|
||||
return None
|
||||
|
||||
|
||||
def deterministic_uuid(string: str) -> str:
|
||||
"""
|
||||
@@ -214,19 +248,55 @@ def import_data(ck, records, name, dry_run, verbose):
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description='Import JSON to CloudKit')
|
||||
p.add_argument('--key-id', default=os.environ.get('CLOUDKIT_KEY_ID'))
|
||||
p.add_argument('--key-file', default=os.environ.get('CLOUDKIT_KEY_FILE'))
|
||||
p.add_argument('--key-id', default=DEFAULT_KEY_ID)
|
||||
p.add_argument('--key-file', default=DEFAULT_KEY_FILE)
|
||||
p.add_argument('--container', default=CONTAINER)
|
||||
p.add_argument('--env', choices=['development', 'production'], default='development')
|
||||
p.add_argument('--data-dir', default='./data')
|
||||
p.add_argument('--stadiums-only', action='store_true')
|
||||
p.add_argument('--games-only', action='store_true')
|
||||
p.add_argument('--league-structure-only', action='store_true', help='Import only league structure')
|
||||
p.add_argument('--team-aliases-only', action='store_true', help='Import only team aliases')
|
||||
p.add_argument('--canonical-only', action='store_true', help='Import only canonical data (league structure + team aliases)')
|
||||
p.add_argument('--delete-all', action='store_true', help='Delete all records before importing')
|
||||
p.add_argument('--delete-only', action='store_true', help='Only delete records, do not import')
|
||||
p.add_argument('--dry-run', action='store_true')
|
||||
p.add_argument('--verbose', '-v', action='store_true')
|
||||
p.add_argument('--interactive', '-i', action='store_true', help='Show interactive menu')
|
||||
args = p.parse_args()
|
||||
|
||||
# Show interactive menu if no action flags provided or --interactive
|
||||
has_action_flag = any([
|
||||
args.stadiums_only, args.games_only, args.league_structure_only,
|
||||
args.team_aliases_only, args.canonical_only, args.delete_all,
|
||||
args.delete_only, args.dry_run
|
||||
])
|
||||
|
||||
if args.interactive or not has_action_flag:
|
||||
choice = show_menu()
|
||||
if choice is None:
|
||||
return
|
||||
|
||||
# Map menu choice to flags
|
||||
if choice == 1: # Import all
|
||||
pass # Default behavior
|
||||
elif choice == 2: # Stadiums only
|
||||
args.stadiums_only = True
|
||||
elif choice == 3: # Games only
|
||||
args.games_only = True
|
||||
elif choice == 4: # League structure only
|
||||
args.league_structure_only = True
|
||||
elif choice == 5: # Team aliases only
|
||||
args.team_aliases_only = True
|
||||
elif choice == 6: # Canonical only
|
||||
args.canonical_only = True
|
||||
elif choice == 7: # Delete all then import
|
||||
args.delete_all = True
|
||||
elif choice == 8: # Delete only
|
||||
args.delete_only = True
|
||||
elif choice == 9: # Dry run
|
||||
args.dry_run = True
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"CloudKit Import {'(DRY RUN)' if args.dry_run else ''}")
|
||||
print(f"{'='*50}")
|
||||
@@ -236,14 +306,16 @@ def main():
|
||||
data_dir = Path(args.data_dir)
|
||||
stadiums = json.load(open(data_dir / 'stadiums.json'))
|
||||
games = json.load(open(data_dir / 'games.json')) if (data_dir / 'games.json').exists() else []
|
||||
print(f"Loaded {len(stadiums)} stadiums, {len(games)} games\n")
|
||||
league_structure = json.load(open(data_dir / 'league_structure.json')) if (data_dir / 'league_structure.json').exists() else []
|
||||
team_aliases = json.load(open(data_dir / 'team_aliases.json')) if (data_dir / 'team_aliases.json').exists() else []
|
||||
print(f"Loaded {len(stadiums)} stadiums, {len(games)} games, {len(league_structure)} league structures, {len(team_aliases)} team aliases\n")
|
||||
|
||||
ck = None
|
||||
if not args.dry_run:
|
||||
if not HAS_CRYPTO:
|
||||
sys.exit("Error: pip install cryptography")
|
||||
if not args.key_id or not args.key_file:
|
||||
sys.exit("Error: --key-id and --key-file required (or use --dry-run)")
|
||||
if not os.path.exists(args.key_file):
|
||||
sys.exit(f"Error: Key file not found: {args.key_file}")
|
||||
ck = CloudKit(args.key_id, open(args.key_file, 'rb').read(), args.container, args.env)
|
||||
|
||||
# Handle deletion
|
||||
@@ -252,8 +324,8 @@ def main():
|
||||
sys.exit("Error: --key-id and --key-file required for deletion")
|
||||
|
||||
print("--- Deleting Existing Records ---")
|
||||
# Delete in order: Games first (has references), then Teams, then Stadiums
|
||||
for record_type in ['Game', 'Team', 'Stadium']:
|
||||
# Delete in order: dependent records first, then base records
|
||||
for record_type in ['Game', 'TeamAlias', 'Team', 'LeagueStructure', 'Stadium']:
|
||||
print(f" Deleting {record_type} records...")
|
||||
deleted = ck.delete_all(record_type, verbose=args.verbose)
|
||||
print(f" Deleted {deleted} {record_type} records")
|
||||
@@ -264,14 +336,21 @@ def main():
|
||||
print()
|
||||
return
|
||||
|
||||
stats = {'stadiums': 0, 'teams': 0, 'games': 0}
|
||||
stats = {'stadiums': 0, 'teams': 0, 'games': 0, 'league_structures': 0, 'team_aliases': 0}
|
||||
team_map = {}
|
||||
|
||||
# Determine what to import based on flags
|
||||
import_stadiums = not args.games_only and not args.league_structure_only and not args.team_aliases_only and not args.canonical_only
|
||||
import_teams = not args.games_only and not args.league_structure_only and not args.team_aliases_only and not args.canonical_only
|
||||
import_games = not args.stadiums_only and not args.league_structure_only and not args.team_aliases_only and not args.canonical_only
|
||||
import_league_structure = args.league_structure_only or args.canonical_only or (not args.stadiums_only and not args.games_only and not args.team_aliases_only)
|
||||
import_team_aliases = args.team_aliases_only or args.canonical_only or (not args.stadiums_only and not args.games_only and not args.league_structure_only)
|
||||
|
||||
# Build stadium UUID lookup (stadium string ID -> UUID)
|
||||
stadium_uuid_map = {s['id']: deterministic_uuid(s['id']) for s in stadiums}
|
||||
|
||||
# Import stadiums & teams
|
||||
if not args.games_only:
|
||||
if import_stadiums:
|
||||
print("--- Stadiums ---")
|
||||
recs = [{
|
||||
'recordType': 'Stadium', 'recordName': stadium_uuid_map[s['id']],
|
||||
@@ -310,7 +389,7 @@ def main():
|
||||
stats['teams'] = import_data(ck, recs, 'teams', args.dry_run, args.verbose)
|
||||
|
||||
# Import games
|
||||
if not args.stadiums_only and games:
|
||||
if import_games and games:
|
||||
# Rebuild team_map if only importing games (--games-only flag)
|
||||
if not team_map:
|
||||
for s in stadiums:
|
||||
@@ -388,8 +467,63 @@ def main():
|
||||
|
||||
stats['games'] = import_data(ck, recs, 'games', args.dry_run, args.verbose)
|
||||
|
||||
# Import league structure
|
||||
if import_league_structure and league_structure:
|
||||
print("--- League Structure ---")
|
||||
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||
recs = [{
|
||||
'recordType': 'LeagueStructure',
|
||||
'recordName': ls['id'], # Use the id as recordName
|
||||
'fields': {
|
||||
'structureId': {'value': ls['id']},
|
||||
'sport': {'value': ls['sport']},
|
||||
'type': {'value': ls['type']},
|
||||
'name': {'value': ls['name']},
|
||||
'displayOrder': {'value': ls['display_order']},
|
||||
'schemaVersion': {'value': 1},
|
||||
'lastModified': {'value': now_ms, 'type': 'TIMESTAMP'},
|
||||
**({'abbreviation': {'value': ls['abbreviation']}} if ls.get('abbreviation') else {}),
|
||||
**({'parentId': {'value': ls['parent_id']}} if ls.get('parent_id') else {}),
|
||||
}
|
||||
} for ls in league_structure]
|
||||
stats['league_structures'] = import_data(ck, recs, 'league structures', args.dry_run, args.verbose)
|
||||
|
||||
# Import team aliases
|
||||
if import_team_aliases and team_aliases:
|
||||
print("--- Team Aliases ---")
|
||||
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
|
||||
recs = []
|
||||
for ta in team_aliases:
|
||||
fields = {
|
||||
'aliasId': {'value': ta['id']},
|
||||
'teamCanonicalId': {'value': ta['team_canonical_id']},
|
||||
'aliasType': {'value': ta['alias_type']},
|
||||
'aliasValue': {'value': ta['alias_value']},
|
||||
'schemaVersion': {'value': 1},
|
||||
'lastModified': {'value': now_ms, 'type': 'TIMESTAMP'},
|
||||
}
|
||||
# Add optional date fields
|
||||
if ta.get('valid_from'):
|
||||
try:
|
||||
dt = datetime.strptime(ta['valid_from'], '%Y-%m-%d')
|
||||
fields['validFrom'] = {'value': int(dt.timestamp() * 1000), 'type': 'TIMESTAMP'}
|
||||
except:
|
||||
pass
|
||||
if ta.get('valid_until'):
|
||||
try:
|
||||
dt = datetime.strptime(ta['valid_until'], '%Y-%m-%d')
|
||||
fields['validUntil'] = {'value': int(dt.timestamp() * 1000), 'type': 'TIMESTAMP'}
|
||||
except:
|
||||
pass
|
||||
recs.append({
|
||||
'recordType': 'TeamAlias',
|
||||
'recordName': ta['id'], # Use the id as recordName
|
||||
'fields': fields
|
||||
})
|
||||
stats['team_aliases'] = import_data(ck, recs, 'team aliases', args.dry_run, args.verbose)
|
||||
|
||||
print(f"\n{'='*50}")
|
||||
print(f"COMPLETE: {stats['stadiums']} stadiums, {stats['teams']} teams, {stats['games']} games")
|
||||
print(f"COMPLETE: {stats['stadiums']} stadiums, {stats['teams']} teams, {stats['games']} games, {stats['league_structures']} league structures, {stats['team_aliases']} team aliases")
|
||||
if args.dry_run:
|
||||
print("[DRY RUN - nothing imported]")
|
||||
print()
|
||||
|
||||
227
Scripts/data/league_structure.json
Normal file
227
Scripts/data/league_structure.json
Normal file
@@ -0,0 +1,227 @@
|
||||
[
|
||||
{
|
||||
"id": "mlb_league",
|
||||
"sport": "MLB",
|
||||
"type": "league",
|
||||
"name": "Major League Baseball",
|
||||
"abbreviation": "MLB",
|
||||
"parent_id": null,
|
||||
"display_order": 0
|
||||
},
|
||||
{
|
||||
"id": "mlb_al",
|
||||
"sport": "MLB",
|
||||
"type": "conference",
|
||||
"name": "American League",
|
||||
"abbreviation": "AL",
|
||||
"parent_id": "mlb_league",
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"id": "mlb_nl",
|
||||
"sport": "MLB",
|
||||
"type": "conference",
|
||||
"name": "National League",
|
||||
"abbreviation": "NL",
|
||||
"parent_id": "mlb_league",
|
||||
"display_order": 2
|
||||
},
|
||||
{
|
||||
"id": "mlb_al_east",
|
||||
"sport": "MLB",
|
||||
"type": "division",
|
||||
"name": "AL East",
|
||||
"abbreviation": null,
|
||||
"parent_id": "mlb_al",
|
||||
"display_order": 3
|
||||
},
|
||||
{
|
||||
"id": "mlb_al_central",
|
||||
"sport": "MLB",
|
||||
"type": "division",
|
||||
"name": "AL Central",
|
||||
"abbreviation": null,
|
||||
"parent_id": "mlb_al",
|
||||
"display_order": 4
|
||||
},
|
||||
{
|
||||
"id": "mlb_al_west",
|
||||
"sport": "MLB",
|
||||
"type": "division",
|
||||
"name": "AL West",
|
||||
"abbreviation": null,
|
||||
"parent_id": "mlb_al",
|
||||
"display_order": 5
|
||||
},
|
||||
{
|
||||
"id": "mlb_nl_east",
|
||||
"sport": "MLB",
|
||||
"type": "division",
|
||||
"name": "NL East",
|
||||
"abbreviation": null,
|
||||
"parent_id": "mlb_nl",
|
||||
"display_order": 6
|
||||
},
|
||||
{
|
||||
"id": "mlb_nl_central",
|
||||
"sport": "MLB",
|
||||
"type": "division",
|
||||
"name": "NL Central",
|
||||
"abbreviation": null,
|
||||
"parent_id": "mlb_nl",
|
||||
"display_order": 7
|
||||
},
|
||||
{
|
||||
"id": "mlb_nl_west",
|
||||
"sport": "MLB",
|
||||
"type": "division",
|
||||
"name": "NL West",
|
||||
"abbreviation": null,
|
||||
"parent_id": "mlb_nl",
|
||||
"display_order": 8
|
||||
},
|
||||
{
|
||||
"id": "nba_league",
|
||||
"sport": "NBA",
|
||||
"type": "league",
|
||||
"name": "National Basketball Association",
|
||||
"abbreviation": "NBA",
|
||||
"parent_id": null,
|
||||
"display_order": 9
|
||||
},
|
||||
{
|
||||
"id": "nba_eastern",
|
||||
"sport": "NBA",
|
||||
"type": "conference",
|
||||
"name": "Eastern Conference",
|
||||
"abbreviation": "East",
|
||||
"parent_id": "nba_league",
|
||||
"display_order": 10
|
||||
},
|
||||
{
|
||||
"id": "nba_western",
|
||||
"sport": "NBA",
|
||||
"type": "conference",
|
||||
"name": "Western Conference",
|
||||
"abbreviation": "West",
|
||||
"parent_id": "nba_league",
|
||||
"display_order": 11
|
||||
},
|
||||
{
|
||||
"id": "nba_atlantic",
|
||||
"sport": "NBA",
|
||||
"type": "division",
|
||||
"name": "Atlantic",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nba_eastern",
|
||||
"display_order": 12
|
||||
},
|
||||
{
|
||||
"id": "nba_central",
|
||||
"sport": "NBA",
|
||||
"type": "division",
|
||||
"name": "Central",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nba_eastern",
|
||||
"display_order": 13
|
||||
},
|
||||
{
|
||||
"id": "nba_southeast",
|
||||
"sport": "NBA",
|
||||
"type": "division",
|
||||
"name": "Southeast",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nba_eastern",
|
||||
"display_order": 14
|
||||
},
|
||||
{
|
||||
"id": "nba_northwest",
|
||||
"sport": "NBA",
|
||||
"type": "division",
|
||||
"name": "Northwest",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nba_western",
|
||||
"display_order": 15
|
||||
},
|
||||
{
|
||||
"id": "nba_pacific",
|
||||
"sport": "NBA",
|
||||
"type": "division",
|
||||
"name": "Pacific",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nba_western",
|
||||
"display_order": 16
|
||||
},
|
||||
{
|
||||
"id": "nba_southwest",
|
||||
"sport": "NBA",
|
||||
"type": "division",
|
||||
"name": "Southwest",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nba_western",
|
||||
"display_order": 17
|
||||
},
|
||||
{
|
||||
"id": "nhl_league",
|
||||
"sport": "NHL",
|
||||
"type": "league",
|
||||
"name": "National Hockey League",
|
||||
"abbreviation": "NHL",
|
||||
"parent_id": null,
|
||||
"display_order": 18
|
||||
},
|
||||
{
|
||||
"id": "nhl_eastern",
|
||||
"sport": "NHL",
|
||||
"type": "conference",
|
||||
"name": "Eastern Conference",
|
||||
"abbreviation": "East",
|
||||
"parent_id": "nhl_league",
|
||||
"display_order": 19
|
||||
},
|
||||
{
|
||||
"id": "nhl_western",
|
||||
"sport": "NHL",
|
||||
"type": "conference",
|
||||
"name": "Western Conference",
|
||||
"abbreviation": "West",
|
||||
"parent_id": "nhl_league",
|
||||
"display_order": 20
|
||||
},
|
||||
{
|
||||
"id": "nhl_atlantic",
|
||||
"sport": "NHL",
|
||||
"type": "division",
|
||||
"name": "Atlantic",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nhl_eastern",
|
||||
"display_order": 21
|
||||
},
|
||||
{
|
||||
"id": "nhl_metropolitan",
|
||||
"sport": "NHL",
|
||||
"type": "division",
|
||||
"name": "Metropolitan",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nhl_eastern",
|
||||
"display_order": 22
|
||||
},
|
||||
{
|
||||
"id": "nhl_central",
|
||||
"sport": "NHL",
|
||||
"type": "division",
|
||||
"name": "Central",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nhl_western",
|
||||
"display_order": 23
|
||||
},
|
||||
{
|
||||
"id": "nhl_pacific",
|
||||
"sport": "NHL",
|
||||
"type": "division",
|
||||
"name": "Pacific",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nhl_western",
|
||||
"display_order": 24
|
||||
}
|
||||
]
|
||||
610
Scripts/data/team_aliases.json
Normal file
610
Scripts/data/team_aliases.json
Normal file
@@ -0,0 +1,610 @@
|
||||
[
|
||||
{
|
||||
"id": "alias_mlb_1",
|
||||
"team_canonical_id": "team_mlb_wsn",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Montreal Expos",
|
||||
"valid_from": "1969-01-01",
|
||||
"valid_until": "2004-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_2",
|
||||
"team_canonical_id": "team_mlb_wsn",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "MON",
|
||||
"valid_from": "1969-01-01",
|
||||
"valid_until": "2004-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_3",
|
||||
"team_canonical_id": "team_mlb_wsn",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Montreal",
|
||||
"valid_from": "1969-01-01",
|
||||
"valid_until": "2004-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_4",
|
||||
"team_canonical_id": "team_mlb_oak",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Kansas City Athletics",
|
||||
"valid_from": "1955-01-01",
|
||||
"valid_until": "1967-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_5",
|
||||
"team_canonical_id": "team_mlb_oak",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "KCA",
|
||||
"valid_from": "1955-01-01",
|
||||
"valid_until": "1967-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_6",
|
||||
"team_canonical_id": "team_mlb_oak",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Kansas City",
|
||||
"valid_from": "1955-01-01",
|
||||
"valid_until": "1967-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_7",
|
||||
"team_canonical_id": "team_mlb_oak",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Philadelphia Athletics",
|
||||
"valid_from": "1901-01-01",
|
||||
"valid_until": "1954-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_8",
|
||||
"team_canonical_id": "team_mlb_oak",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "PHA",
|
||||
"valid_from": "1901-01-01",
|
||||
"valid_until": "1954-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_9",
|
||||
"team_canonical_id": "team_mlb_oak",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Philadelphia",
|
||||
"valid_from": "1901-01-01",
|
||||
"valid_until": "1954-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_10",
|
||||
"team_canonical_id": "team_mlb_cle",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Cleveland Indians",
|
||||
"valid_from": "1915-01-01",
|
||||
"valid_until": "2021-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_11",
|
||||
"team_canonical_id": "team_mlb_tbr",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Tampa Bay Devil Rays",
|
||||
"valid_from": "1998-01-01",
|
||||
"valid_until": "2007-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_12",
|
||||
"team_canonical_id": "team_mlb_mia",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Florida Marlins",
|
||||
"valid_from": "1993-01-01",
|
||||
"valid_until": "2011-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_13",
|
||||
"team_canonical_id": "team_mlb_mia",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Florida",
|
||||
"valid_from": "1993-01-01",
|
||||
"valid_until": "2011-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_14",
|
||||
"team_canonical_id": "team_mlb_laa",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Anaheim Angels",
|
||||
"valid_from": "1997-01-01",
|
||||
"valid_until": "2004-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_15",
|
||||
"team_canonical_id": "team_mlb_laa",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Los Angeles Angels of Anaheim",
|
||||
"valid_from": "2005-01-01",
|
||||
"valid_until": "2015-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_16",
|
||||
"team_canonical_id": "team_mlb_laa",
|
||||
"alias_type": "name",
|
||||
"alias_value": "California Angels",
|
||||
"valid_from": "1965-01-01",
|
||||
"valid_until": "1996-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_17",
|
||||
"team_canonical_id": "team_mlb_tex",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Washington Senators",
|
||||
"valid_from": "1961-01-01",
|
||||
"valid_until": "1971-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_18",
|
||||
"team_canonical_id": "team_mlb_tex",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "WS2",
|
||||
"valid_from": "1961-01-01",
|
||||
"valid_until": "1971-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_19",
|
||||
"team_canonical_id": "team_mlb_tex",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Washington",
|
||||
"valid_from": "1961-01-01",
|
||||
"valid_until": "1971-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_20",
|
||||
"team_canonical_id": "team_mlb_mil",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Seattle Pilots",
|
||||
"valid_from": "1969-01-01",
|
||||
"valid_until": "1969-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_21",
|
||||
"team_canonical_id": "team_mlb_mil",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "SEP",
|
||||
"valid_from": "1969-01-01",
|
||||
"valid_until": "1969-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_22",
|
||||
"team_canonical_id": "team_mlb_mil",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Seattle",
|
||||
"valid_from": "1969-01-01",
|
||||
"valid_until": "1969-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_23",
|
||||
"team_canonical_id": "team_mlb_hou",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Houston Colt .45s",
|
||||
"valid_from": "1962-01-01",
|
||||
"valid_until": "1964-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_24",
|
||||
"team_canonical_id": "team_nba_brk",
|
||||
"alias_type": "name",
|
||||
"alias_value": "New Jersey Nets",
|
||||
"valid_from": "1977-01-01",
|
||||
"valid_until": "2012-04-30"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_25",
|
||||
"team_canonical_id": "team_nba_brk",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "NJN",
|
||||
"valid_from": "1977-01-01",
|
||||
"valid_until": "2012-04-30"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_26",
|
||||
"team_canonical_id": "team_nba_brk",
|
||||
"alias_type": "city",
|
||||
"alias_value": "New Jersey",
|
||||
"valid_from": "1977-01-01",
|
||||
"valid_until": "2012-04-30"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_27",
|
||||
"team_canonical_id": "team_nba_brk",
|
||||
"alias_type": "name",
|
||||
"alias_value": "New York Nets",
|
||||
"valid_from": "1968-01-01",
|
||||
"valid_until": "1977-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_28",
|
||||
"team_canonical_id": "team_nba_okc",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Seattle SuperSonics",
|
||||
"valid_from": "1967-01-01",
|
||||
"valid_until": "2008-07-01"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_29",
|
||||
"team_canonical_id": "team_nba_okc",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "SEA",
|
||||
"valid_from": "1967-01-01",
|
||||
"valid_until": "2008-07-01"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_30",
|
||||
"team_canonical_id": "team_nba_okc",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Seattle",
|
||||
"valid_from": "1967-01-01",
|
||||
"valid_until": "2008-07-01"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_31",
|
||||
"team_canonical_id": "team_nba_mem",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Vancouver Grizzlies",
|
||||
"valid_from": "1995-01-01",
|
||||
"valid_until": "2001-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_32",
|
||||
"team_canonical_id": "team_nba_mem",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "VAN",
|
||||
"valid_from": "1995-01-01",
|
||||
"valid_until": "2001-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_33",
|
||||
"team_canonical_id": "team_nba_mem",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Vancouver",
|
||||
"valid_from": "1995-01-01",
|
||||
"valid_until": "2001-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_34",
|
||||
"team_canonical_id": "team_nba_nop",
|
||||
"alias_type": "name",
|
||||
"alias_value": "New Orleans Hornets",
|
||||
"valid_from": "2002-01-01",
|
||||
"valid_until": "2013-04-30"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_35",
|
||||
"team_canonical_id": "team_nba_nop",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "NOH",
|
||||
"valid_from": "2002-01-01",
|
||||
"valid_until": "2013-04-30"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_36",
|
||||
"team_canonical_id": "team_nba_nop",
|
||||
"alias_type": "name",
|
||||
"alias_value": "New Orleans/Oklahoma City Hornets",
|
||||
"valid_from": "2005-01-01",
|
||||
"valid_until": "2007-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_37",
|
||||
"team_canonical_id": "team_nba_cho",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Charlotte Bobcats",
|
||||
"valid_from": "2004-01-01",
|
||||
"valid_until": "2014-04-30"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_38",
|
||||
"team_canonical_id": "team_nba_cho",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "CHA",
|
||||
"valid_from": "2004-01-01",
|
||||
"valid_until": "2014-04-30"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_39",
|
||||
"team_canonical_id": "team_nba_was",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Washington Bullets",
|
||||
"valid_from": "1974-01-01",
|
||||
"valid_until": "1997-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_40",
|
||||
"team_canonical_id": "team_nba_was",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Capital Bullets",
|
||||
"valid_from": "1973-01-01",
|
||||
"valid_until": "1973-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_41",
|
||||
"team_canonical_id": "team_nba_was",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Baltimore Bullets",
|
||||
"valid_from": "1963-01-01",
|
||||
"valid_until": "1972-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_42",
|
||||
"team_canonical_id": "team_nba_lac",
|
||||
"alias_type": "name",
|
||||
"alias_value": "San Diego Clippers",
|
||||
"valid_from": "1978-01-01",
|
||||
"valid_until": "1984-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_43",
|
||||
"team_canonical_id": "team_nba_lac",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "SDC",
|
||||
"valid_from": "1978-01-01",
|
||||
"valid_until": "1984-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_44",
|
||||
"team_canonical_id": "team_nba_lac",
|
||||
"alias_type": "city",
|
||||
"alias_value": "San Diego",
|
||||
"valid_from": "1978-01-01",
|
||||
"valid_until": "1984-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_45",
|
||||
"team_canonical_id": "team_nba_lac",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Buffalo Braves",
|
||||
"valid_from": "1970-01-01",
|
||||
"valid_until": "1978-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_46",
|
||||
"team_canonical_id": "team_nba_lac",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "BUF",
|
||||
"valid_from": "1970-01-01",
|
||||
"valid_until": "1978-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_47",
|
||||
"team_canonical_id": "team_nba_lac",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Buffalo",
|
||||
"valid_from": "1970-01-01",
|
||||
"valid_until": "1978-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_48",
|
||||
"team_canonical_id": "team_nba_sac",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Kansas City Kings",
|
||||
"valid_from": "1975-01-01",
|
||||
"valid_until": "1985-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_49",
|
||||
"team_canonical_id": "team_nba_sac",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "KCK",
|
||||
"valid_from": "1975-01-01",
|
||||
"valid_until": "1985-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_50",
|
||||
"team_canonical_id": "team_nba_sac",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Kansas City",
|
||||
"valid_from": "1975-01-01",
|
||||
"valid_until": "1985-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_51",
|
||||
"team_canonical_id": "team_nba_uta",
|
||||
"alias_type": "name",
|
||||
"alias_value": "New Orleans Jazz",
|
||||
"valid_from": "1974-01-01",
|
||||
"valid_until": "1979-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_52",
|
||||
"team_canonical_id": "team_nba_uta",
|
||||
"alias_type": "city",
|
||||
"alias_value": "New Orleans",
|
||||
"valid_from": "1974-01-01",
|
||||
"valid_until": "1979-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_53",
|
||||
"team_canonical_id": "team_nhl_ari",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Arizona Coyotes",
|
||||
"valid_from": "2014-01-01",
|
||||
"valid_until": "2024-04-30"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_54",
|
||||
"team_canonical_id": "team_nhl_ari",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Phoenix Coyotes",
|
||||
"valid_from": "1996-01-01",
|
||||
"valid_until": "2013-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_55",
|
||||
"team_canonical_id": "team_nhl_ari",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "PHX",
|
||||
"valid_from": "1996-01-01",
|
||||
"valid_until": "2013-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_56",
|
||||
"team_canonical_id": "team_nhl_ari",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Phoenix",
|
||||
"valid_from": "1996-01-01",
|
||||
"valid_until": "2013-12-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_57",
|
||||
"team_canonical_id": "team_nhl_ari",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Winnipeg Jets",
|
||||
"valid_from": "1979-01-01",
|
||||
"valid_until": "1996-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_58",
|
||||
"team_canonical_id": "team_nhl_car",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Hartford Whalers",
|
||||
"valid_from": "1979-01-01",
|
||||
"valid_until": "1997-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_59",
|
||||
"team_canonical_id": "team_nhl_car",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "HFD",
|
||||
"valid_from": "1979-01-01",
|
||||
"valid_until": "1997-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_60",
|
||||
"team_canonical_id": "team_nhl_car",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Hartford",
|
||||
"valid_from": "1979-01-01",
|
||||
"valid_until": "1997-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_61",
|
||||
"team_canonical_id": "team_nhl_col",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Quebec Nordiques",
|
||||
"valid_from": "1979-01-01",
|
||||
"valid_until": "1995-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_62",
|
||||
"team_canonical_id": "team_nhl_col",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "QUE",
|
||||
"valid_from": "1979-01-01",
|
||||
"valid_until": "1995-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_63",
|
||||
"team_canonical_id": "team_nhl_col",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Quebec",
|
||||
"valid_from": "1979-01-01",
|
||||
"valid_until": "1995-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_64",
|
||||
"team_canonical_id": "team_nhl_dal",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Minnesota North Stars",
|
||||
"valid_from": "1967-01-01",
|
||||
"valid_until": "1993-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_65",
|
||||
"team_canonical_id": "team_nhl_dal",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "MNS",
|
||||
"valid_from": "1967-01-01",
|
||||
"valid_until": "1993-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_66",
|
||||
"team_canonical_id": "team_nhl_dal",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Minnesota",
|
||||
"valid_from": "1967-01-01",
|
||||
"valid_until": "1993-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_67",
|
||||
"team_canonical_id": "team_nhl_njd",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Colorado Rockies",
|
||||
"valid_from": "1976-01-01",
|
||||
"valid_until": "1982-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_68",
|
||||
"team_canonical_id": "team_nhl_njd",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "CLR",
|
||||
"valid_from": "1976-01-01",
|
||||
"valid_until": "1982-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_69",
|
||||
"team_canonical_id": "team_nhl_njd",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Colorado",
|
||||
"valid_from": "1976-01-01",
|
||||
"valid_until": "1982-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_70",
|
||||
"team_canonical_id": "team_nhl_njd",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Kansas City Scouts",
|
||||
"valid_from": "1974-01-01",
|
||||
"valid_until": "1976-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_71",
|
||||
"team_canonical_id": "team_nhl_njd",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "KCS",
|
||||
"valid_from": "1974-01-01",
|
||||
"valid_until": "1976-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_72",
|
||||
"team_canonical_id": "team_nhl_njd",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Kansas City",
|
||||
"valid_from": "1974-01-01",
|
||||
"valid_until": "1976-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_73",
|
||||
"team_canonical_id": "team_nhl_wpg",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Atlanta Thrashers",
|
||||
"valid_from": "1999-01-01",
|
||||
"valid_until": "2011-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_74",
|
||||
"team_canonical_id": "team_nhl_wpg",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "ATL",
|
||||
"valid_from": "1999-01-01",
|
||||
"valid_until": "2011-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_75",
|
||||
"team_canonical_id": "team_nhl_wpg",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Atlanta",
|
||||
"valid_from": "1999-01-01",
|
||||
"valid_until": "2011-05-31"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_76",
|
||||
"team_canonical_id": "team_nhl_fla",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Miami",
|
||||
"valid_from": "1993-01-01",
|
||||
"valid_until": "1998-12-31"
|
||||
}
|
||||
]
|
||||
405
Scripts/generate_canonical_data.py
Normal file
405
Scripts/generate_canonical_data.py
Normal file
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Generate Canonical Data for SportsTime App
|
||||
==========================================
|
||||
Generates team_aliases.json and league_structure.json from team mappings.
|
||||
|
||||
Usage:
|
||||
python generate_canonical_data.py
|
||||
python generate_canonical_data.py --output ./data
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# =============================================================================
|
||||
# LEAGUE STRUCTURE
|
||||
# =============================================================================
|
||||
|
||||
MLB_STRUCTURE = {
|
||||
"leagues": [
|
||||
{"id": "mlb_al", "name": "American League", "abbreviation": "AL"},
|
||||
{"id": "mlb_nl", "name": "National League", "abbreviation": "NL"},
|
||||
],
|
||||
"divisions": [
|
||||
# American League
|
||||
{"id": "mlb_al_east", "name": "AL East", "parent_id": "mlb_al", "teams": ["NYY", "BOS", "TOR", "BAL", "TBR"]},
|
||||
{"id": "mlb_al_central", "name": "AL Central", "parent_id": "mlb_al", "teams": ["CLE", "DET", "MIN", "CHW", "KCR"]},
|
||||
{"id": "mlb_al_west", "name": "AL West", "parent_id": "mlb_al", "teams": ["HOU", "SEA", "TEX", "LAA", "OAK"]},
|
||||
# National League
|
||||
{"id": "mlb_nl_east", "name": "NL East", "parent_id": "mlb_nl", "teams": ["ATL", "PHI", "NYM", "MIA", "WSN"]},
|
||||
{"id": "mlb_nl_central", "name": "NL Central", "parent_id": "mlb_nl", "teams": ["MIL", "CHC", "STL", "PIT", "CIN"]},
|
||||
{"id": "mlb_nl_west", "name": "NL West", "parent_id": "mlb_nl", "teams": ["LAD", "ARI", "SDP", "SFG", "COL"]},
|
||||
]
|
||||
}
|
||||
|
||||
NBA_STRUCTURE = {
|
||||
"conferences": [
|
||||
{"id": "nba_eastern", "name": "Eastern Conference", "abbreviation": "East"},
|
||||
{"id": "nba_western", "name": "Western Conference", "abbreviation": "West"},
|
||||
],
|
||||
"divisions": [
|
||||
# Eastern Conference
|
||||
{"id": "nba_atlantic", "name": "Atlantic", "parent_id": "nba_eastern", "teams": ["BOS", "BRK", "NYK", "PHI", "TOR"]},
|
||||
{"id": "nba_central", "name": "Central", "parent_id": "nba_eastern", "teams": ["CHI", "CLE", "DET", "IND", "MIL"]},
|
||||
{"id": "nba_southeast", "name": "Southeast", "parent_id": "nba_eastern", "teams": ["ATL", "CHO", "MIA", "ORL", "WAS"]},
|
||||
# Western Conference
|
||||
{"id": "nba_northwest", "name": "Northwest", "parent_id": "nba_western", "teams": ["DEN", "MIN", "OKC", "POR", "UTA"]},
|
||||
{"id": "nba_pacific", "name": "Pacific", "parent_id": "nba_western", "teams": ["GSW", "LAC", "LAL", "PHO", "SAC"]},
|
||||
{"id": "nba_southwest", "name": "Southwest", "parent_id": "nba_western", "teams": ["DAL", "HOU", "MEM", "NOP", "SAS"]},
|
||||
]
|
||||
}
|
||||
|
||||
NHL_STRUCTURE = {
|
||||
"conferences": [
|
||||
{"id": "nhl_eastern", "name": "Eastern Conference", "abbreviation": "East"},
|
||||
{"id": "nhl_western", "name": "Western Conference", "abbreviation": "West"},
|
||||
],
|
||||
"divisions": [
|
||||
# Eastern Conference
|
||||
{"id": "nhl_atlantic", "name": "Atlantic", "parent_id": "nhl_eastern", "teams": ["BOS", "BUF", "DET", "FLA", "MTL", "OTT", "TBL", "TOR"]},
|
||||
{"id": "nhl_metropolitan", "name": "Metropolitan", "parent_id": "nhl_eastern", "teams": ["CAR", "CBJ", "NJD", "NYI", "NYR", "PHI", "PIT", "WSH"]},
|
||||
# Western Conference
|
||||
{"id": "nhl_central", "name": "Central", "parent_id": "nhl_western", "teams": ["ARI", "CHI", "COL", "DAL", "MIN", "NSH", "STL", "WPG"]},
|
||||
{"id": "nhl_pacific", "name": "Pacific", "parent_id": "nhl_western", "teams": ["ANA", "CGY", "EDM", "LAK", "SEA", "SJS", "VAN", "VGK"]},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEAM ALIASES (Historical name changes, relocations, abbreviation changes)
|
||||
# =============================================================================
|
||||
|
||||
# Format: {current_abbrev: [(alias_type, alias_value, valid_from, valid_until), ...]}
|
||||
|
||||
MLB_ALIASES = {
|
||||
# Washington Nationals (formerly Montreal Expos)
|
||||
"WSN": [
|
||||
("name", "Montreal Expos", "1969-01-01", "2004-12-31"),
|
||||
("abbreviation", "MON", "1969-01-01", "2004-12-31"),
|
||||
("city", "Montreal", "1969-01-01", "2004-12-31"),
|
||||
],
|
||||
# Oakland Athletics (moving to Sacramento, formerly in Kansas City and Philadelphia)
|
||||
"OAK": [
|
||||
("name", "Kansas City Athletics", "1955-01-01", "1967-12-31"),
|
||||
("abbreviation", "KCA", "1955-01-01", "1967-12-31"),
|
||||
("city", "Kansas City", "1955-01-01", "1967-12-31"),
|
||||
("name", "Philadelphia Athletics", "1901-01-01", "1954-12-31"),
|
||||
("abbreviation", "PHA", "1901-01-01", "1954-12-31"),
|
||||
("city", "Philadelphia", "1901-01-01", "1954-12-31"),
|
||||
],
|
||||
# Cleveland Guardians (formerly Indians)
|
||||
"CLE": [
|
||||
("name", "Cleveland Indians", "1915-01-01", "2021-12-31"),
|
||||
],
|
||||
# Tampa Bay Rays (formerly Devil Rays)
|
||||
"TBR": [
|
||||
("name", "Tampa Bay Devil Rays", "1998-01-01", "2007-12-31"),
|
||||
],
|
||||
# Miami Marlins (formerly Florida Marlins)
|
||||
"MIA": [
|
||||
("name", "Florida Marlins", "1993-01-01", "2011-12-31"),
|
||||
("city", "Florida", "1993-01-01", "2011-12-31"),
|
||||
],
|
||||
# Los Angeles Angels (various names)
|
||||
"LAA": [
|
||||
("name", "Anaheim Angels", "1997-01-01", "2004-12-31"),
|
||||
("name", "Los Angeles Angels of Anaheim", "2005-01-01", "2015-12-31"),
|
||||
("name", "California Angels", "1965-01-01", "1996-12-31"),
|
||||
],
|
||||
# Texas Rangers (formerly Washington Senators II)
|
||||
"TEX": [
|
||||
("name", "Washington Senators", "1961-01-01", "1971-12-31"),
|
||||
("abbreviation", "WS2", "1961-01-01", "1971-12-31"),
|
||||
("city", "Washington", "1961-01-01", "1971-12-31"),
|
||||
],
|
||||
# Milwaukee Brewers (briefly Seattle Pilots)
|
||||
"MIL": [
|
||||
("name", "Seattle Pilots", "1969-01-01", "1969-12-31"),
|
||||
("abbreviation", "SEP", "1969-01-01", "1969-12-31"),
|
||||
("city", "Seattle", "1969-01-01", "1969-12-31"),
|
||||
],
|
||||
# Houston Astros (formerly Colt .45s)
|
||||
"HOU": [
|
||||
("name", "Houston Colt .45s", "1962-01-01", "1964-12-31"),
|
||||
],
|
||||
}
|
||||
|
||||
NBA_ALIASES = {
|
||||
# Brooklyn Nets (formerly New Jersey Nets, New York Nets)
|
||||
"BRK": [
|
||||
("name", "New Jersey Nets", "1977-01-01", "2012-04-30"),
|
||||
("abbreviation", "NJN", "1977-01-01", "2012-04-30"),
|
||||
("city", "New Jersey", "1977-01-01", "2012-04-30"),
|
||||
("name", "New York Nets", "1968-01-01", "1977-12-31"),
|
||||
],
|
||||
# Oklahoma City Thunder (formerly Seattle SuperSonics)
|
||||
"OKC": [
|
||||
("name", "Seattle SuperSonics", "1967-01-01", "2008-07-01"),
|
||||
("abbreviation", "SEA", "1967-01-01", "2008-07-01"),
|
||||
("city", "Seattle", "1967-01-01", "2008-07-01"),
|
||||
],
|
||||
# Memphis Grizzlies (formerly Vancouver Grizzlies)
|
||||
"MEM": [
|
||||
("name", "Vancouver Grizzlies", "1995-01-01", "2001-05-31"),
|
||||
("abbreviation", "VAN", "1995-01-01", "2001-05-31"),
|
||||
("city", "Vancouver", "1995-01-01", "2001-05-31"),
|
||||
],
|
||||
# New Orleans Pelicans (formerly Hornets, formerly Charlotte Hornets original)
|
||||
"NOP": [
|
||||
("name", "New Orleans Hornets", "2002-01-01", "2013-04-30"),
|
||||
("abbreviation", "NOH", "2002-01-01", "2013-04-30"),
|
||||
("name", "New Orleans/Oklahoma City Hornets", "2005-01-01", "2007-12-31"),
|
||||
],
|
||||
# Charlotte Hornets (current, formerly Bobcats)
|
||||
"CHO": [
|
||||
("name", "Charlotte Bobcats", "2004-01-01", "2014-04-30"),
|
||||
("abbreviation", "CHA", "2004-01-01", "2014-04-30"),
|
||||
],
|
||||
# Washington Wizards (formerly Bullets)
|
||||
"WAS": [
|
||||
("name", "Washington Bullets", "1974-01-01", "1997-05-31"),
|
||||
("name", "Capital Bullets", "1973-01-01", "1973-12-31"),
|
||||
("name", "Baltimore Bullets", "1963-01-01", "1972-12-31"),
|
||||
],
|
||||
# Los Angeles Clippers (formerly San Diego, Buffalo)
|
||||
"LAC": [
|
||||
("name", "San Diego Clippers", "1978-01-01", "1984-05-31"),
|
||||
("abbreviation", "SDC", "1978-01-01", "1984-05-31"),
|
||||
("city", "San Diego", "1978-01-01", "1984-05-31"),
|
||||
("name", "Buffalo Braves", "1970-01-01", "1978-05-31"),
|
||||
("abbreviation", "BUF", "1970-01-01", "1978-05-31"),
|
||||
("city", "Buffalo", "1970-01-01", "1978-05-31"),
|
||||
],
|
||||
# Sacramento Kings (formerly Kansas City Kings, etc.)
|
||||
"SAC": [
|
||||
("name", "Kansas City Kings", "1975-01-01", "1985-05-31"),
|
||||
("abbreviation", "KCK", "1975-01-01", "1985-05-31"),
|
||||
("city", "Kansas City", "1975-01-01", "1985-05-31"),
|
||||
],
|
||||
# Utah Jazz (formerly New Orleans Jazz)
|
||||
"UTA": [
|
||||
("name", "New Orleans Jazz", "1974-01-01", "1979-05-31"),
|
||||
("city", "New Orleans", "1974-01-01", "1979-05-31"),
|
||||
],
|
||||
}
|
||||
|
||||
NHL_ALIASES = {
|
||||
# Arizona/Utah Hockey Club (formerly Phoenix Coyotes, originally Winnipeg Jets)
|
||||
"ARI": [
|
||||
("name", "Arizona Coyotes", "2014-01-01", "2024-04-30"),
|
||||
("name", "Phoenix Coyotes", "1996-01-01", "2013-12-31"),
|
||||
("abbreviation", "PHX", "1996-01-01", "2013-12-31"),
|
||||
("city", "Phoenix", "1996-01-01", "2013-12-31"),
|
||||
("name", "Winnipeg Jets", "1979-01-01", "1996-05-31"), # Original Jets
|
||||
],
|
||||
# Carolina Hurricanes (formerly Hartford Whalers)
|
||||
"CAR": [
|
||||
("name", "Hartford Whalers", "1979-01-01", "1997-05-31"),
|
||||
("abbreviation", "HFD", "1979-01-01", "1997-05-31"),
|
||||
("city", "Hartford", "1979-01-01", "1997-05-31"),
|
||||
],
|
||||
# Colorado Avalanche (formerly Quebec Nordiques)
|
||||
"COL": [
|
||||
("name", "Quebec Nordiques", "1979-01-01", "1995-05-31"),
|
||||
("abbreviation", "QUE", "1979-01-01", "1995-05-31"),
|
||||
("city", "Quebec", "1979-01-01", "1995-05-31"),
|
||||
],
|
||||
# Dallas Stars (formerly Minnesota North Stars)
|
||||
"DAL": [
|
||||
("name", "Minnesota North Stars", "1967-01-01", "1993-05-31"),
|
||||
("abbreviation", "MNS", "1967-01-01", "1993-05-31"),
|
||||
("city", "Minnesota", "1967-01-01", "1993-05-31"),
|
||||
],
|
||||
# New Jersey Devils (formerly Kansas City Scouts, Colorado Rockies)
|
||||
"NJD": [
|
||||
("name", "Colorado Rockies", "1976-01-01", "1982-05-31"),
|
||||
("abbreviation", "CLR", "1976-01-01", "1982-05-31"),
|
||||
("city", "Colorado", "1976-01-01", "1982-05-31"),
|
||||
("name", "Kansas City Scouts", "1974-01-01", "1976-05-31"),
|
||||
("abbreviation", "KCS", "1974-01-01", "1976-05-31"),
|
||||
("city", "Kansas City", "1974-01-01", "1976-05-31"),
|
||||
],
|
||||
# Winnipeg Jets (current, formerly Atlanta Thrashers)
|
||||
"WPG": [
|
||||
("name", "Atlanta Thrashers", "1999-01-01", "2011-05-31"),
|
||||
("abbreviation", "ATL", "1999-01-01", "2011-05-31"),
|
||||
("city", "Atlanta", "1999-01-01", "2011-05-31"),
|
||||
],
|
||||
# Florida Panthers (originally in Miami)
|
||||
"FLA": [
|
||||
("city", "Miami", "1993-01-01", "1998-12-31"),
|
||||
],
|
||||
# Vegas Golden Knights (no aliases, expansion team)
|
||||
# Seattle Kraken (no aliases, expansion team)
|
||||
}
|
||||
|
||||
|
||||
def generate_league_structure() -> list[dict]:
|
||||
"""Generate league_structure.json data."""
|
||||
structures = []
|
||||
order = 0
|
||||
|
||||
# MLB
|
||||
structures.append({
|
||||
"id": "mlb_league",
|
||||
"sport": "MLB",
|
||||
"type": "league",
|
||||
"name": "Major League Baseball",
|
||||
"abbreviation": "MLB",
|
||||
"parent_id": None,
|
||||
"display_order": order,
|
||||
})
|
||||
order += 1
|
||||
|
||||
for league in MLB_STRUCTURE["leagues"]:
|
||||
structures.append({
|
||||
"id": league["id"],
|
||||
"sport": "MLB",
|
||||
"type": "conference", # AL/NL are like conferences
|
||||
"name": league["name"],
|
||||
"abbreviation": league["abbreviation"],
|
||||
"parent_id": "mlb_league",
|
||||
"display_order": order,
|
||||
})
|
||||
order += 1
|
||||
|
||||
for div in MLB_STRUCTURE["divisions"]:
|
||||
structures.append({
|
||||
"id": div["id"],
|
||||
"sport": "MLB",
|
||||
"type": "division",
|
||||
"name": div["name"],
|
||||
"abbreviation": None,
|
||||
"parent_id": div["parent_id"],
|
||||
"display_order": order,
|
||||
})
|
||||
order += 1
|
||||
|
||||
# NBA
|
||||
structures.append({
|
||||
"id": "nba_league",
|
||||
"sport": "NBA",
|
||||
"type": "league",
|
||||
"name": "National Basketball Association",
|
||||
"abbreviation": "NBA",
|
||||
"parent_id": None,
|
||||
"display_order": order,
|
||||
})
|
||||
order += 1
|
||||
|
||||
for conf in NBA_STRUCTURE["conferences"]:
|
||||
structures.append({
|
||||
"id": conf["id"],
|
||||
"sport": "NBA",
|
||||
"type": "conference",
|
||||
"name": conf["name"],
|
||||
"abbreviation": conf["abbreviation"],
|
||||
"parent_id": "nba_league",
|
||||
"display_order": order,
|
||||
})
|
||||
order += 1
|
||||
|
||||
for div in NBA_STRUCTURE["divisions"]:
|
||||
structures.append({
|
||||
"id": div["id"],
|
||||
"sport": "NBA",
|
||||
"type": "division",
|
||||
"name": div["name"],
|
||||
"abbreviation": None,
|
||||
"parent_id": div["parent_id"],
|
||||
"display_order": order,
|
||||
})
|
||||
order += 1
|
||||
|
||||
# NHL
|
||||
structures.append({
|
||||
"id": "nhl_league",
|
||||
"sport": "NHL",
|
||||
"type": "league",
|
||||
"name": "National Hockey League",
|
||||
"abbreviation": "NHL",
|
||||
"parent_id": None,
|
||||
"display_order": order,
|
||||
})
|
||||
order += 1
|
||||
|
||||
for conf in NHL_STRUCTURE["conferences"]:
|
||||
structures.append({
|
||||
"id": conf["id"],
|
||||
"sport": "NHL",
|
||||
"type": "conference",
|
||||
"name": conf["name"],
|
||||
"abbreviation": conf["abbreviation"],
|
||||
"parent_id": "nhl_league",
|
||||
"display_order": order,
|
||||
})
|
||||
order += 1
|
||||
|
||||
for div in NHL_STRUCTURE["divisions"]:
|
||||
structures.append({
|
||||
"id": div["id"],
|
||||
"sport": "NHL",
|
||||
"type": "division",
|
||||
"name": div["name"],
|
||||
"abbreviation": None,
|
||||
"parent_id": div["parent_id"],
|
||||
"display_order": order,
|
||||
})
|
||||
order += 1
|
||||
|
||||
return structures
|
||||
|
||||
|
||||
def generate_team_aliases() -> list[dict]:
|
||||
"""Generate team_aliases.json data."""
|
||||
aliases = []
|
||||
alias_id = 1
|
||||
|
||||
for sport, sport_aliases in [("MLB", MLB_ALIASES), ("NBA", NBA_ALIASES), ("NHL", NHL_ALIASES)]:
|
||||
for current_abbrev, alias_list in sport_aliases.items():
|
||||
team_canonical_id = f"team_{sport.lower()}_{current_abbrev.lower()}"
|
||||
|
||||
for alias_type, alias_value, valid_from, valid_until in alias_list:
|
||||
aliases.append({
|
||||
"id": f"alias_{sport.lower()}_{alias_id}",
|
||||
"team_canonical_id": team_canonical_id,
|
||||
"alias_type": alias_type,
|
||||
"alias_value": alias_value,
|
||||
"valid_from": valid_from,
|
||||
"valid_until": valid_until,
|
||||
})
|
||||
alias_id += 1
|
||||
|
||||
return aliases
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Generate canonical data JSON files')
|
||||
parser.add_argument('--output', type=str, default='./data', help='Output directory')
|
||||
args = parser.parse_args()
|
||||
|
||||
output_dir = Path(args.output)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate league structure
|
||||
print("Generating league_structure.json...")
|
||||
league_structure = generate_league_structure()
|
||||
with open(output_dir / 'league_structure.json', 'w') as f:
|
||||
json.dump(league_structure, f, indent=2)
|
||||
print(f" Created {len(league_structure)} structure entries")
|
||||
|
||||
# Generate team aliases
|
||||
print("Generating team_aliases.json...")
|
||||
team_aliases = generate_team_aliases()
|
||||
with open(output_dir / 'team_aliases.json', 'w') as f:
|
||||
json.dump(team_aliases, f, indent=2)
|
||||
print(f" Created {len(team_aliases)} alias entries")
|
||||
|
||||
print(f"\nFiles written to {output_dir}")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -15,6 +15,8 @@ enum CKRecordType {
|
||||
static let stadium = "Stadium"
|
||||
static let game = "Game"
|
||||
static let sport = "Sport"
|
||||
static let leagueStructure = "LeagueStructure"
|
||||
static let teamAlias = "TeamAlias"
|
||||
}
|
||||
|
||||
// MARK: - CKTeam
|
||||
@@ -100,6 +102,7 @@ struct CKStadium {
|
||||
static let capacityKey = "capacity"
|
||||
static let yearOpenedKey = "yearOpened"
|
||||
static let imageURLKey = "imageURL"
|
||||
static let sportKey = "sport"
|
||||
|
||||
let record: CKRecord
|
||||
|
||||
@@ -117,6 +120,7 @@ struct CKStadium {
|
||||
record[CKStadium.capacityKey] = stadium.capacity
|
||||
record[CKStadium.yearOpenedKey] = stadium.yearOpened
|
||||
record[CKStadium.imageURLKey] = stadium.imageURL?.absoluteString
|
||||
record[CKStadium.sportKey] = stadium.sport.rawValue
|
||||
self.record = record
|
||||
}
|
||||
|
||||
@@ -133,6 +137,8 @@ struct CKStadium {
|
||||
let location = record[CKStadium.locationKey] as? CLLocation
|
||||
let capacity = record[CKStadium.capacityKey] as? Int ?? 0
|
||||
let imageURL = (record[CKStadium.imageURLKey] as? String).flatMap { URL(string: $0) }
|
||||
let sportRaw = record[CKStadium.sportKey] as? String ?? "MLB"
|
||||
let sport = Sport(rawValue: sportRaw) ?? .mlb
|
||||
|
||||
return Stadium(
|
||||
id: id,
|
||||
@@ -142,6 +148,7 @@ struct CKStadium {
|
||||
latitude: location?.coordinate.latitude ?? 0,
|
||||
longitude: location?.coordinate.longitude ?? 0,
|
||||
capacity: capacity,
|
||||
sport: sport,
|
||||
yearOpened: record[CKStadium.yearOpenedKey] as? Int,
|
||||
imageURL: imageURL
|
||||
)
|
||||
@@ -203,3 +210,123 @@ struct CKGame {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CKLeagueStructure
|
||||
|
||||
struct CKLeagueStructure {
|
||||
static let idKey = "structureId"
|
||||
static let sportKey = "sport"
|
||||
static let typeKey = "type"
|
||||
static let nameKey = "name"
|
||||
static let abbreviationKey = "abbreviation"
|
||||
static let parentIdKey = "parentId"
|
||||
static let displayOrderKey = "displayOrder"
|
||||
static let schemaVersionKey = "schemaVersion"
|
||||
static let lastModifiedKey = "lastModified"
|
||||
|
||||
let record: CKRecord
|
||||
|
||||
init(record: CKRecord) {
|
||||
self.record = record
|
||||
}
|
||||
|
||||
init(model: LeagueStructureModel) {
|
||||
let record = CKRecord(recordType: CKRecordType.leagueStructure, recordID: CKRecord.ID(recordName: model.id))
|
||||
record[CKLeagueStructure.idKey] = model.id
|
||||
record[CKLeagueStructure.sportKey] = model.sport
|
||||
record[CKLeagueStructure.typeKey] = model.structureTypeRaw
|
||||
record[CKLeagueStructure.nameKey] = model.name
|
||||
record[CKLeagueStructure.abbreviationKey] = model.abbreviation
|
||||
record[CKLeagueStructure.parentIdKey] = model.parentId
|
||||
record[CKLeagueStructure.displayOrderKey] = model.displayOrder
|
||||
record[CKLeagueStructure.schemaVersionKey] = model.schemaVersion
|
||||
record[CKLeagueStructure.lastModifiedKey] = model.lastModified
|
||||
self.record = record
|
||||
}
|
||||
|
||||
/// Convert to SwiftData model for local storage
|
||||
func toModel() -> LeagueStructureModel? {
|
||||
guard let id = record[CKLeagueStructure.idKey] as? String,
|
||||
let sport = record[CKLeagueStructure.sportKey] as? String,
|
||||
let typeRaw = record[CKLeagueStructure.typeKey] as? String,
|
||||
let structureType = LeagueStructureType(rawValue: typeRaw),
|
||||
let name = record[CKLeagueStructure.nameKey] as? String
|
||||
else { return nil }
|
||||
|
||||
let abbreviation = record[CKLeagueStructure.abbreviationKey] as? String
|
||||
let parentId = record[CKLeagueStructure.parentIdKey] as? String
|
||||
let displayOrder = record[CKLeagueStructure.displayOrderKey] as? Int ?? 0
|
||||
let schemaVersion = record[CKLeagueStructure.schemaVersionKey] as? Int ?? SchemaVersion.current
|
||||
let lastModified = record[CKLeagueStructure.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
|
||||
|
||||
return LeagueStructureModel(
|
||||
id: id,
|
||||
sport: sport,
|
||||
structureType: structureType,
|
||||
name: name,
|
||||
abbreviation: abbreviation,
|
||||
parentId: parentId,
|
||||
displayOrder: displayOrder,
|
||||
schemaVersion: schemaVersion,
|
||||
lastModified: lastModified
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CKTeamAlias
|
||||
|
||||
struct CKTeamAlias {
|
||||
static let idKey = "aliasId"
|
||||
static let teamCanonicalIdKey = "teamCanonicalId"
|
||||
static let aliasTypeKey = "aliasType"
|
||||
static let aliasValueKey = "aliasValue"
|
||||
static let validFromKey = "validFrom"
|
||||
static let validUntilKey = "validUntil"
|
||||
static let schemaVersionKey = "schemaVersion"
|
||||
static let lastModifiedKey = "lastModified"
|
||||
|
||||
let record: CKRecord
|
||||
|
||||
init(record: CKRecord) {
|
||||
self.record = record
|
||||
}
|
||||
|
||||
init(model: TeamAlias) {
|
||||
let record = CKRecord(recordType: CKRecordType.teamAlias, recordID: CKRecord.ID(recordName: model.id))
|
||||
record[CKTeamAlias.idKey] = model.id
|
||||
record[CKTeamAlias.teamCanonicalIdKey] = model.teamCanonicalId
|
||||
record[CKTeamAlias.aliasTypeKey] = model.aliasTypeRaw
|
||||
record[CKTeamAlias.aliasValueKey] = model.aliasValue
|
||||
record[CKTeamAlias.validFromKey] = model.validFrom
|
||||
record[CKTeamAlias.validUntilKey] = model.validUntil
|
||||
record[CKTeamAlias.schemaVersionKey] = model.schemaVersion
|
||||
record[CKTeamAlias.lastModifiedKey] = model.lastModified
|
||||
self.record = record
|
||||
}
|
||||
|
||||
/// Convert to SwiftData model for local storage
|
||||
func toModel() -> TeamAlias? {
|
||||
guard let id = record[CKTeamAlias.idKey] as? String,
|
||||
let teamCanonicalId = record[CKTeamAlias.teamCanonicalIdKey] as? String,
|
||||
let aliasTypeRaw = record[CKTeamAlias.aliasTypeKey] as? String,
|
||||
let aliasType = TeamAliasType(rawValue: aliasTypeRaw),
|
||||
let aliasValue = record[CKTeamAlias.aliasValueKey] as? String
|
||||
else { return nil }
|
||||
|
||||
let validFrom = record[CKTeamAlias.validFromKey] as? Date
|
||||
let validUntil = record[CKTeamAlias.validUntilKey] as? Date
|
||||
let schemaVersion = record[CKTeamAlias.schemaVersionKey] as? Int ?? SchemaVersion.current
|
||||
let lastModified = record[CKTeamAlias.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
|
||||
|
||||
return TeamAlias(
|
||||
id: id,
|
||||
teamCanonicalId: teamCanonicalId,
|
||||
aliasType: aliasType,
|
||||
aliasValue: aliasValue,
|
||||
validFrom: validFrom,
|
||||
validUntil: validUntil,
|
||||
schemaVersion: schemaVersion,
|
||||
lastModified: lastModified
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
647
SportsTime/Core/Models/Domain/AchievementDefinitions.swift
Normal file
647
SportsTime/Core/Models/Domain/AchievementDefinitions.swift
Normal file
@@ -0,0 +1,647 @@
|
||||
//
|
||||
// AchievementDefinitions.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Registry of all achievement types and their requirements.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Achievement Category
|
||||
|
||||
enum AchievementCategory: String, Codable, CaseIterable {
|
||||
case count // Milestone counts (1, 5, 10, etc.)
|
||||
case division // Complete a division
|
||||
case conference // Complete a conference
|
||||
case league // Complete entire league
|
||||
case journey // Special journey-based achievements
|
||||
case special // Special one-off achievements
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .count: return "Milestones"
|
||||
case .division: return "Divisions"
|
||||
case .conference: return "Conferences"
|
||||
case .league: return "Leagues"
|
||||
case .journey: return "Journeys"
|
||||
case .special: return "Special"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievement Definition
|
||||
|
||||
struct AchievementDefinition: Identifiable, Hashable {
|
||||
let id: String
|
||||
let name: String
|
||||
let description: String
|
||||
let category: AchievementCategory
|
||||
let sport: Sport? // nil for cross-sport achievements
|
||||
let iconName: String
|
||||
let iconColor: Color
|
||||
let requirement: AchievementRequirement
|
||||
let sortOrder: Int
|
||||
|
||||
// Optional division/conference reference
|
||||
let divisionId: String?
|
||||
let conferenceId: String?
|
||||
|
||||
init(
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
category: AchievementCategory,
|
||||
sport: Sport? = nil,
|
||||
iconName: String,
|
||||
iconColor: Color,
|
||||
requirement: AchievementRequirement,
|
||||
sortOrder: Int = 0,
|
||||
divisionId: String? = nil,
|
||||
conferenceId: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.category = category
|
||||
self.sport = sport
|
||||
self.iconName = iconName
|
||||
self.iconColor = iconColor
|
||||
self.requirement = requirement
|
||||
self.sortOrder = sortOrder
|
||||
self.divisionId = divisionId
|
||||
self.conferenceId = conferenceId
|
||||
}
|
||||
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
|
||||
static func == (lhs: AchievementDefinition, rhs: AchievementDefinition) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievement Requirement
|
||||
|
||||
enum AchievementRequirement: Hashable {
|
||||
case visitCount(Int) // Visit N unique stadiums
|
||||
case visitCountForSport(Int, Sport) // Visit N stadiums for a specific sport
|
||||
case completeDivision(String) // Complete all stadiums in a division
|
||||
case completeConference(String) // Complete all stadiums in a conference
|
||||
case completeLeague(Sport) // Complete all stadiums in a league
|
||||
case visitsInDays(Int, days: Int) // Visit N stadiums within N days
|
||||
case multipleLeagues(Int) // Visit stadiums from N different leagues
|
||||
case firstVisit // First stadium visit ever
|
||||
case specificStadium(String) // Visit a specific stadium
|
||||
}
|
||||
|
||||
// MARK: - Achievement Registry
|
||||
|
||||
enum AchievementRegistry {
|
||||
|
||||
// MARK: - All Definitions
|
||||
|
||||
/// All achievement definitions sorted by category and sort order
|
||||
static let all: [AchievementDefinition] = {
|
||||
var definitions: [AchievementDefinition] = []
|
||||
definitions.append(contentsOf: countAchievements)
|
||||
definitions.append(contentsOf: mlbDivisionAchievements)
|
||||
definitions.append(contentsOf: mlbConferenceAchievements)
|
||||
definitions.append(contentsOf: nbaDivisionAchievements)
|
||||
definitions.append(contentsOf: nbaConferenceAchievements)
|
||||
definitions.append(contentsOf: nhlDivisionAchievements)
|
||||
definitions.append(contentsOf: nhlConferenceAchievements)
|
||||
definitions.append(contentsOf: leagueAchievements)
|
||||
definitions.append(contentsOf: journeyAchievements)
|
||||
definitions.append(contentsOf: specialAchievements)
|
||||
return definitions.sorted { $0.sortOrder < $1.sortOrder }
|
||||
}()
|
||||
|
||||
// MARK: - Count Achievements
|
||||
|
||||
static let countAchievements: [AchievementDefinition] = [
|
||||
AchievementDefinition(
|
||||
id: "first_visit",
|
||||
name: "First Pitch",
|
||||
description: "Visit your first stadium",
|
||||
category: .count,
|
||||
iconName: "1.circle.fill",
|
||||
iconColor: .green,
|
||||
requirement: .firstVisit,
|
||||
sortOrder: 100
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "count_5",
|
||||
name: "Getting Started",
|
||||
description: "Visit 5 different stadiums",
|
||||
category: .count,
|
||||
iconName: "5.circle.fill",
|
||||
iconColor: .blue,
|
||||
requirement: .visitCount(5),
|
||||
sortOrder: 101
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "count_10",
|
||||
name: "Double Digits",
|
||||
description: "Visit 10 different stadiums",
|
||||
category: .count,
|
||||
iconName: "10.circle.fill",
|
||||
iconColor: .orange,
|
||||
requirement: .visitCount(10),
|
||||
sortOrder: 102
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "count_20",
|
||||
name: "Veteran Fan",
|
||||
description: "Visit 20 different stadiums",
|
||||
category: .count,
|
||||
iconName: "20.circle.fill",
|
||||
iconColor: .purple,
|
||||
requirement: .visitCount(20),
|
||||
sortOrder: 103
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "count_30",
|
||||
name: "Stadium Enthusiast",
|
||||
description: "Visit 30 different stadiums",
|
||||
category: .count,
|
||||
iconName: "30.circle.fill",
|
||||
iconColor: .red,
|
||||
requirement: .visitCount(30),
|
||||
sortOrder: 104
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "count_50",
|
||||
name: "Road Warrior",
|
||||
description: "Visit 50 different stadiums",
|
||||
category: .count,
|
||||
iconName: "50.circle.fill",
|
||||
iconColor: .yellow,
|
||||
requirement: .visitCount(50),
|
||||
sortOrder: 105
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "count_75",
|
||||
name: "Stadium Expert",
|
||||
description: "Visit 75 different stadiums",
|
||||
category: .count,
|
||||
iconName: "75.circle.fill",
|
||||
iconColor: .mint,
|
||||
requirement: .visitCount(75),
|
||||
sortOrder: 106
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "count_all",
|
||||
name: "Stadium Master",
|
||||
description: "Visit all 92 MLB, NBA, and NHL stadiums",
|
||||
category: .count,
|
||||
iconName: "star.circle.fill",
|
||||
iconColor: .yellow,
|
||||
requirement: .visitCount(92),
|
||||
sortOrder: 107
|
||||
)
|
||||
]
|
||||
|
||||
// MARK: - MLB Division Achievements
|
||||
|
||||
static let mlbDivisionAchievements: [AchievementDefinition] = [
|
||||
AchievementDefinition(
|
||||
id: "mlb_al_east_complete",
|
||||
name: "AL East Champion",
|
||||
description: "Visit all AL East stadiums",
|
||||
category: .division,
|
||||
sport: .mlb,
|
||||
iconName: "baseball.fill",
|
||||
iconColor: .red,
|
||||
requirement: .completeDivision("mlb_al_east"),
|
||||
sortOrder: 200,
|
||||
divisionId: "mlb_al_east"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "mlb_al_central_complete",
|
||||
name: "AL Central Champion",
|
||||
description: "Visit all AL Central stadiums",
|
||||
category: .division,
|
||||
sport: .mlb,
|
||||
iconName: "baseball.fill",
|
||||
iconColor: .red,
|
||||
requirement: .completeDivision("mlb_al_central"),
|
||||
sortOrder: 201,
|
||||
divisionId: "mlb_al_central"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "mlb_al_west_complete",
|
||||
name: "AL West Champion",
|
||||
description: "Visit all AL West stadiums",
|
||||
category: .division,
|
||||
sport: .mlb,
|
||||
iconName: "baseball.fill",
|
||||
iconColor: .red,
|
||||
requirement: .completeDivision("mlb_al_west"),
|
||||
sortOrder: 202,
|
||||
divisionId: "mlb_al_west"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "mlb_nl_east_complete",
|
||||
name: "NL East Champion",
|
||||
description: "Visit all NL East stadiums",
|
||||
category: .division,
|
||||
sport: .mlb,
|
||||
iconName: "baseball.fill",
|
||||
iconColor: .red,
|
||||
requirement: .completeDivision("mlb_nl_east"),
|
||||
sortOrder: 203,
|
||||
divisionId: "mlb_nl_east"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "mlb_nl_central_complete",
|
||||
name: "NL Central Champion",
|
||||
description: "Visit all NL Central stadiums",
|
||||
category: .division,
|
||||
sport: .mlb,
|
||||
iconName: "baseball.fill",
|
||||
iconColor: .red,
|
||||
requirement: .completeDivision("mlb_nl_central"),
|
||||
sortOrder: 204,
|
||||
divisionId: "mlb_nl_central"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "mlb_nl_west_complete",
|
||||
name: "NL West Champion",
|
||||
description: "Visit all NL West stadiums",
|
||||
category: .division,
|
||||
sport: .mlb,
|
||||
iconName: "baseball.fill",
|
||||
iconColor: .red,
|
||||
requirement: .completeDivision("mlb_nl_west"),
|
||||
sortOrder: 205,
|
||||
divisionId: "mlb_nl_west"
|
||||
)
|
||||
]
|
||||
|
||||
// MARK: - MLB Conference Achievements
|
||||
|
||||
static let mlbConferenceAchievements: [AchievementDefinition] = [
|
||||
AchievementDefinition(
|
||||
id: "mlb_al_complete",
|
||||
name: "American League Complete",
|
||||
description: "Visit all American League stadiums",
|
||||
category: .conference,
|
||||
sport: .mlb,
|
||||
iconName: "baseball.circle.fill",
|
||||
iconColor: .red,
|
||||
requirement: .completeConference("mlb_al"),
|
||||
sortOrder: 300,
|
||||
conferenceId: "mlb_al"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "mlb_nl_complete",
|
||||
name: "National League Complete",
|
||||
description: "Visit all National League stadiums",
|
||||
category: .conference,
|
||||
sport: .mlb,
|
||||
iconName: "baseball.circle.fill",
|
||||
iconColor: .red,
|
||||
requirement: .completeConference("mlb_nl"),
|
||||
sortOrder: 301,
|
||||
conferenceId: "mlb_nl"
|
||||
)
|
||||
]
|
||||
|
||||
// MARK: - NBA Division Achievements
|
||||
|
||||
static let nbaDivisionAchievements: [AchievementDefinition] = [
|
||||
AchievementDefinition(
|
||||
id: "nba_atlantic_complete",
|
||||
name: "Atlantic Division Champion",
|
||||
description: "Visit all Atlantic Division arenas",
|
||||
category: .division,
|
||||
sport: .nba,
|
||||
iconName: "basketball.fill",
|
||||
iconColor: .orange,
|
||||
requirement: .completeDivision("nba_atlantic"),
|
||||
sortOrder: 210,
|
||||
divisionId: "nba_atlantic"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "nba_central_complete",
|
||||
name: "Central Division Champion",
|
||||
description: "Visit all Central Division arenas",
|
||||
category: .division,
|
||||
sport: .nba,
|
||||
iconName: "basketball.fill",
|
||||
iconColor: .orange,
|
||||
requirement: .completeDivision("nba_central"),
|
||||
sortOrder: 211,
|
||||
divisionId: "nba_central"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "nba_southeast_complete",
|
||||
name: "Southeast Division Champion",
|
||||
description: "Visit all Southeast Division arenas",
|
||||
category: .division,
|
||||
sport: .nba,
|
||||
iconName: "basketball.fill",
|
||||
iconColor: .orange,
|
||||
requirement: .completeDivision("nba_southeast"),
|
||||
sortOrder: 212,
|
||||
divisionId: "nba_southeast"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "nba_northwest_complete",
|
||||
name: "Northwest Division Champion",
|
||||
description: "Visit all Northwest Division arenas",
|
||||
category: .division,
|
||||
sport: .nba,
|
||||
iconName: "basketball.fill",
|
||||
iconColor: .orange,
|
||||
requirement: .completeDivision("nba_northwest"),
|
||||
sortOrder: 213,
|
||||
divisionId: "nba_northwest"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "nba_pacific_complete",
|
||||
name: "Pacific Division Champion",
|
||||
description: "Visit all Pacific Division arenas",
|
||||
category: .division,
|
||||
sport: .nba,
|
||||
iconName: "basketball.fill",
|
||||
iconColor: .orange,
|
||||
requirement: .completeDivision("nba_pacific"),
|
||||
sortOrder: 214,
|
||||
divisionId: "nba_pacific"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "nba_southwest_complete",
|
||||
name: "Southwest Division Champion",
|
||||
description: "Visit all Southwest Division arenas",
|
||||
category: .division,
|
||||
sport: .nba,
|
||||
iconName: "basketball.fill",
|
||||
iconColor: .orange,
|
||||
requirement: .completeDivision("nba_southwest"),
|
||||
sortOrder: 215,
|
||||
divisionId: "nba_southwest"
|
||||
)
|
||||
]
|
||||
|
||||
// MARK: - NBA Conference Achievements
|
||||
|
||||
static let nbaConferenceAchievements: [AchievementDefinition] = [
|
||||
AchievementDefinition(
|
||||
id: "nba_eastern_complete",
|
||||
name: "Eastern Conference Complete",
|
||||
description: "Visit all Eastern Conference arenas",
|
||||
category: .conference,
|
||||
sport: .nba,
|
||||
iconName: "basketball.circle.fill",
|
||||
iconColor: .orange,
|
||||
requirement: .completeConference("nba_eastern"),
|
||||
sortOrder: 310,
|
||||
conferenceId: "nba_eastern"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "nba_western_complete",
|
||||
name: "Western Conference Complete",
|
||||
description: "Visit all Western Conference arenas",
|
||||
category: .conference,
|
||||
sport: .nba,
|
||||
iconName: "basketball.circle.fill",
|
||||
iconColor: .orange,
|
||||
requirement: .completeConference("nba_western"),
|
||||
sortOrder: 311,
|
||||
conferenceId: "nba_western"
|
||||
)
|
||||
]
|
||||
|
||||
// MARK: - NHL Division Achievements
|
||||
|
||||
static let nhlDivisionAchievements: [AchievementDefinition] = [
|
||||
AchievementDefinition(
|
||||
id: "nhl_atlantic_complete",
|
||||
name: "NHL Atlantic Champion",
|
||||
description: "Visit all Atlantic Division arenas",
|
||||
category: .division,
|
||||
sport: .nhl,
|
||||
iconName: "hockey.puck.fill",
|
||||
iconColor: .blue,
|
||||
requirement: .completeDivision("nhl_atlantic"),
|
||||
sortOrder: 220,
|
||||
divisionId: "nhl_atlantic"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "nhl_metropolitan_complete",
|
||||
name: "Metropolitan Champion",
|
||||
description: "Visit all Metropolitan Division arenas",
|
||||
category: .division,
|
||||
sport: .nhl,
|
||||
iconName: "hockey.puck.fill",
|
||||
iconColor: .blue,
|
||||
requirement: .completeDivision("nhl_metropolitan"),
|
||||
sortOrder: 221,
|
||||
divisionId: "nhl_metropolitan"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "nhl_central_complete",
|
||||
name: "NHL Central Champion",
|
||||
description: "Visit all Central Division arenas",
|
||||
category: .division,
|
||||
sport: .nhl,
|
||||
iconName: "hockey.puck.fill",
|
||||
iconColor: .blue,
|
||||
requirement: .completeDivision("nhl_central"),
|
||||
sortOrder: 222,
|
||||
divisionId: "nhl_central"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "nhl_pacific_complete",
|
||||
name: "NHL Pacific Champion",
|
||||
description: "Visit all Pacific Division arenas",
|
||||
category: .division,
|
||||
sport: .nhl,
|
||||
iconName: "hockey.puck.fill",
|
||||
iconColor: .blue,
|
||||
requirement: .completeDivision("nhl_pacific"),
|
||||
sortOrder: 223,
|
||||
divisionId: "nhl_pacific"
|
||||
)
|
||||
]
|
||||
|
||||
// MARK: - NHL Conference Achievements
|
||||
|
||||
static let nhlConferenceAchievements: [AchievementDefinition] = [
|
||||
AchievementDefinition(
|
||||
id: "nhl_eastern_complete",
|
||||
name: "NHL Eastern Conference Complete",
|
||||
description: "Visit all Eastern Conference arenas",
|
||||
category: .conference,
|
||||
sport: .nhl,
|
||||
iconName: "hockey.puck.circle.fill",
|
||||
iconColor: .blue,
|
||||
requirement: .completeConference("nhl_eastern"),
|
||||
sortOrder: 320,
|
||||
conferenceId: "nhl_eastern"
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "nhl_western_complete",
|
||||
name: "NHL Western Conference Complete",
|
||||
description: "Visit all Western Conference arenas",
|
||||
category: .conference,
|
||||
sport: .nhl,
|
||||
iconName: "hockey.puck.circle.fill",
|
||||
iconColor: .blue,
|
||||
requirement: .completeConference("nhl_western"),
|
||||
sortOrder: 321,
|
||||
conferenceId: "nhl_western"
|
||||
)
|
||||
]
|
||||
|
||||
// MARK: - League Achievements
|
||||
|
||||
static let leagueAchievements: [AchievementDefinition] = [
|
||||
AchievementDefinition(
|
||||
id: "mlb_complete",
|
||||
name: "Diamond Collector",
|
||||
description: "Visit all 30 MLB stadiums",
|
||||
category: .league,
|
||||
sport: .mlb,
|
||||
iconName: "diamond.fill",
|
||||
iconColor: .red,
|
||||
requirement: .completeLeague(.mlb),
|
||||
sortOrder: 400
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "nba_complete",
|
||||
name: "Court Master",
|
||||
description: "Visit all 30 NBA arenas",
|
||||
category: .league,
|
||||
sport: .nba,
|
||||
iconName: "trophy.fill",
|
||||
iconColor: .orange,
|
||||
requirement: .completeLeague(.nba),
|
||||
sortOrder: 401
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "nhl_complete",
|
||||
name: "Ice Warrior",
|
||||
description: "Visit all 32 NHL arenas",
|
||||
category: .league,
|
||||
sport: .nhl,
|
||||
iconName: "crown.fill",
|
||||
iconColor: .blue,
|
||||
requirement: .completeLeague(.nhl),
|
||||
sortOrder: 402
|
||||
)
|
||||
]
|
||||
|
||||
// MARK: - Journey Achievements
|
||||
|
||||
static let journeyAchievements: [AchievementDefinition] = [
|
||||
AchievementDefinition(
|
||||
id: "journey_weekend_warrior",
|
||||
name: "Weekend Warrior",
|
||||
description: "Visit 3 stadiums in 3 days",
|
||||
category: .journey,
|
||||
iconName: "figure.run",
|
||||
iconColor: .green,
|
||||
requirement: .visitsInDays(3, days: 3),
|
||||
sortOrder: 500
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "journey_road_trip",
|
||||
name: "Road Trip Champion",
|
||||
description: "Visit 5 stadiums in 7 days",
|
||||
category: .journey,
|
||||
iconName: "car.fill",
|
||||
iconColor: .cyan,
|
||||
requirement: .visitsInDays(5, days: 7),
|
||||
sortOrder: 501
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "journey_marathon",
|
||||
name: "Stadium Marathon",
|
||||
description: "Visit 7 stadiums in 10 days",
|
||||
category: .journey,
|
||||
iconName: "flame.fill",
|
||||
iconColor: .orange,
|
||||
requirement: .visitsInDays(7, days: 10),
|
||||
sortOrder: 502
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "journey_triple_threat",
|
||||
name: "Triple Threat",
|
||||
description: "Visit stadiums from all 3 leagues (MLB, NBA, NHL)",
|
||||
category: .journey,
|
||||
iconName: "star.fill",
|
||||
iconColor: .yellow,
|
||||
requirement: .multipleLeagues(3),
|
||||
sortOrder: 503
|
||||
)
|
||||
]
|
||||
|
||||
// MARK: - Special Achievements
|
||||
|
||||
static let specialAchievements: [AchievementDefinition] = [
|
||||
AchievementDefinition(
|
||||
id: "special_fenway",
|
||||
name: "Green Monster",
|
||||
description: "Visit Fenway Park",
|
||||
category: .special,
|
||||
sport: .mlb,
|
||||
iconName: "building.columns.fill",
|
||||
iconColor: .green,
|
||||
requirement: .specificStadium("stadium_mlb_bos"),
|
||||
sortOrder: 600
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "special_wrigley",
|
||||
name: "Ivy League",
|
||||
description: "Visit Wrigley Field",
|
||||
category: .special,
|
||||
sport: .mlb,
|
||||
iconName: "leaf.fill",
|
||||
iconColor: .green,
|
||||
requirement: .specificStadium("stadium_mlb_chc"),
|
||||
sortOrder: 601
|
||||
),
|
||||
AchievementDefinition(
|
||||
id: "special_msg",
|
||||
name: "World's Most Famous Arena",
|
||||
description: "Visit Madison Square Garden",
|
||||
category: .special,
|
||||
sport: .nba,
|
||||
iconName: "sparkles",
|
||||
iconColor: .orange,
|
||||
requirement: .specificStadium("stadium_nba_nyk"),
|
||||
sortOrder: 602
|
||||
)
|
||||
]
|
||||
|
||||
// MARK: - Lookup Methods
|
||||
|
||||
/// Get achievement by ID
|
||||
static func achievement(byId id: String) -> AchievementDefinition? {
|
||||
all.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Get achievements by category
|
||||
static func achievements(forCategory category: AchievementCategory) -> [AchievementDefinition] {
|
||||
all.filter { $0.category == category }
|
||||
}
|
||||
|
||||
/// Get achievements for a sport
|
||||
static func achievements(forSport sport: Sport) -> [AchievementDefinition] {
|
||||
all.filter { $0.sport == sport || $0.sport == nil }
|
||||
}
|
||||
|
||||
/// Get division achievements for a sport
|
||||
static func divisionAchievements(forSport sport: Sport) -> [AchievementDefinition] {
|
||||
all.filter { $0.sport == sport && $0.category == .division }
|
||||
}
|
||||
|
||||
/// Get conference achievements for a sport
|
||||
static func conferenceAchievements(forSport sport: Sport) -> [AchievementDefinition] {
|
||||
all.filter { $0.sport == sport && $0.category == .conference }
|
||||
}
|
||||
}
|
||||
119
SportsTime/Core/Models/Domain/Division.swift
Normal file
119
SportsTime/Core/Models/Domain/Division.swift
Normal file
@@ -0,0 +1,119 @@
|
||||
//
|
||||
// Division.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Domain model for league structure: divisions and conferences.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Division
|
||||
|
||||
struct Division: Identifiable, Codable, Hashable {
|
||||
let id: String // e.g., "mlb_nl_west"
|
||||
let name: String // e.g., "NL West"
|
||||
let conference: String // e.g., "National League"
|
||||
let conferenceId: String // e.g., "mlb_nl"
|
||||
let sport: Sport
|
||||
var teamCanonicalIds: [String] // Canonical team IDs in this division
|
||||
|
||||
var teamCount: Int { teamCanonicalIds.count }
|
||||
}
|
||||
|
||||
// MARK: - Conference
|
||||
|
||||
struct Conference: Identifiable, Codable, Hashable {
|
||||
let id: String // e.g., "mlb_nl"
|
||||
let name: String // e.g., "National League"
|
||||
let abbreviation: String? // e.g., "NL"
|
||||
let sport: Sport
|
||||
let divisionIds: [String] // Division IDs in this conference
|
||||
}
|
||||
|
||||
// MARK: - League Structure Provider
|
||||
|
||||
/// Provides access to league structure data (divisions, conferences).
|
||||
/// Reads from SwiftData LeagueStructureModel and CanonicalTeam.
|
||||
enum LeagueStructure {
|
||||
|
||||
// MARK: - Static Division Definitions
|
||||
|
||||
/// All MLB divisions
|
||||
static let mlbDivisions: [Division] = [
|
||||
Division(id: "mlb_al_east", name: "AL East", conference: "American League", conferenceId: "mlb_al", sport: .mlb, teamCanonicalIds: []),
|
||||
Division(id: "mlb_al_central", name: "AL Central", conference: "American League", conferenceId: "mlb_al", sport: .mlb, teamCanonicalIds: []),
|
||||
Division(id: "mlb_al_west", name: "AL West", conference: "American League", conferenceId: "mlb_al", sport: .mlb, teamCanonicalIds: []),
|
||||
Division(id: "mlb_nl_east", name: "NL East", conference: "National League", conferenceId: "mlb_nl", sport: .mlb, teamCanonicalIds: []),
|
||||
Division(id: "mlb_nl_central", name: "NL Central", conference: "National League", conferenceId: "mlb_nl", sport: .mlb, teamCanonicalIds: []),
|
||||
Division(id: "mlb_nl_west", name: "NL West", conference: "National League", conferenceId: "mlb_nl", sport: .mlb, teamCanonicalIds: [])
|
||||
]
|
||||
|
||||
/// All NBA divisions
|
||||
static let nbaDivisions: [Division] = [
|
||||
Division(id: "nba_atlantic", name: "Atlantic", conference: "Eastern Conference", conferenceId: "nba_eastern", sport: .nba, teamCanonicalIds: []),
|
||||
Division(id: "nba_central", name: "Central", conference: "Eastern Conference", conferenceId: "nba_eastern", sport: .nba, teamCanonicalIds: []),
|
||||
Division(id: "nba_southeast", name: "Southeast", conference: "Eastern Conference", conferenceId: "nba_eastern", sport: .nba, teamCanonicalIds: []),
|
||||
Division(id: "nba_northwest", name: "Northwest", conference: "Western Conference", conferenceId: "nba_western", sport: .nba, teamCanonicalIds: []),
|
||||
Division(id: "nba_pacific", name: "Pacific", conference: "Western Conference", conferenceId: "nba_western", sport: .nba, teamCanonicalIds: []),
|
||||
Division(id: "nba_southwest", name: "Southwest", conference: "Western Conference", conferenceId: "nba_western", sport: .nba, teamCanonicalIds: [])
|
||||
]
|
||||
|
||||
/// All NHL divisions
|
||||
static let nhlDivisions: [Division] = [
|
||||
Division(id: "nhl_atlantic", name: "Atlantic", conference: "Eastern Conference", conferenceId: "nhl_eastern", sport: .nhl, teamCanonicalIds: []),
|
||||
Division(id: "nhl_metropolitan", name: "Metropolitan", conference: "Eastern Conference", conferenceId: "nhl_eastern", sport: .nhl, teamCanonicalIds: []),
|
||||
Division(id: "nhl_central", name: "Central", conference: "Western Conference", conferenceId: "nhl_western", sport: .nhl, teamCanonicalIds: []),
|
||||
Division(id: "nhl_pacific", name: "Pacific", conference: "Western Conference", conferenceId: "nhl_western", sport: .nhl, teamCanonicalIds: [])
|
||||
]
|
||||
|
||||
/// All conferences
|
||||
static let conferences: [Conference] = [
|
||||
// MLB
|
||||
Conference(id: "mlb_al", name: "American League", abbreviation: "AL", sport: .mlb, divisionIds: ["mlb_al_east", "mlb_al_central", "mlb_al_west"]),
|
||||
Conference(id: "mlb_nl", name: "National League", abbreviation: "NL", sport: .mlb, divisionIds: ["mlb_nl_east", "mlb_nl_central", "mlb_nl_west"]),
|
||||
// NBA
|
||||
Conference(id: "nba_eastern", name: "Eastern Conference", abbreviation: "East", sport: .nba, divisionIds: ["nba_atlantic", "nba_central", "nba_southeast"]),
|
||||
Conference(id: "nba_western", name: "Western Conference", abbreviation: "West", sport: .nba, divisionIds: ["nba_northwest", "nba_pacific", "nba_southwest"]),
|
||||
// NHL
|
||||
Conference(id: "nhl_eastern", name: "Eastern Conference", abbreviation: "East", sport: .nhl, divisionIds: ["nhl_atlantic", "nhl_metropolitan"]),
|
||||
Conference(id: "nhl_western", name: "Western Conference", abbreviation: "West", sport: .nhl, divisionIds: ["nhl_central", "nhl_pacific"])
|
||||
]
|
||||
|
||||
// MARK: - Lookup Methods
|
||||
|
||||
/// Get all divisions for a sport
|
||||
static func divisions(for sport: Sport) -> [Division] {
|
||||
switch sport {
|
||||
case .mlb: return mlbDivisions
|
||||
case .nba: return nbaDivisions
|
||||
case .nhl: return nhlDivisions
|
||||
default: return []
|
||||
}
|
||||
}
|
||||
|
||||
/// Get all conferences for a sport
|
||||
static func conferences(for sport: Sport) -> [Conference] {
|
||||
conferences.filter { $0.sport == sport }
|
||||
}
|
||||
|
||||
/// Find division by ID
|
||||
static func division(byId id: String) -> Division? {
|
||||
let allDivisions = mlbDivisions + nbaDivisions + nhlDivisions
|
||||
return allDivisions.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Find conference by ID
|
||||
static func conference(byId id: String) -> Conference? {
|
||||
conferences.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// Get total stadium count for a sport
|
||||
static func stadiumCount(for sport: Sport) -> Int {
|
||||
switch sport {
|
||||
case .mlb: return 30
|
||||
case .nba: return 30
|
||||
case .nhl: return 32
|
||||
default: return 0
|
||||
}
|
||||
}
|
||||
}
|
||||
232
SportsTime/Core/Models/Domain/Progress.swift
Normal file
232
SportsTime/Core/Models/Domain/Progress.swift
Normal file
@@ -0,0 +1,232 @@
|
||||
//
|
||||
// Progress.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Domain models for tracking stadium visit progress and achievements.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - League Progress
|
||||
|
||||
/// Progress tracking for a single sport/league
|
||||
struct LeagueProgress: Identifiable {
|
||||
let sport: Sport
|
||||
let totalStadiums: Int
|
||||
let visitedStadiums: Int
|
||||
let stadiumsVisited: [Stadium]
|
||||
let stadiumsRemaining: [Stadium]
|
||||
|
||||
var id: String { sport.rawValue }
|
||||
|
||||
var completionPercentage: Double {
|
||||
guard totalStadiums > 0 else { return 0 }
|
||||
return Double(visitedStadiums) / Double(totalStadiums) * 100
|
||||
}
|
||||
|
||||
var isComplete: Bool {
|
||||
totalStadiums > 0 && visitedStadiums >= totalStadiums
|
||||
}
|
||||
|
||||
var progressFraction: Double {
|
||||
guard totalStadiums > 0 else { return 0 }
|
||||
return Double(visitedStadiums) / Double(totalStadiums)
|
||||
}
|
||||
|
||||
var progressDescription: String {
|
||||
"\(visitedStadiums)/\(totalStadiums)"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Division Progress
|
||||
|
||||
/// Progress tracking for a single division
|
||||
struct DivisionProgress: Identifiable {
|
||||
let division: Division
|
||||
let totalStadiums: Int
|
||||
let visitedStadiums: Int
|
||||
let stadiumsVisited: [Stadium]
|
||||
let stadiumsRemaining: [Stadium]
|
||||
|
||||
var id: String { division.id }
|
||||
|
||||
var completionPercentage: Double {
|
||||
guard totalStadiums > 0 else { return 0 }
|
||||
return Double(visitedStadiums) / Double(totalStadiums) * 100
|
||||
}
|
||||
|
||||
var isComplete: Bool {
|
||||
totalStadiums > 0 && visitedStadiums >= totalStadiums
|
||||
}
|
||||
|
||||
var progressFraction: Double {
|
||||
guard totalStadiums > 0 else { return 0 }
|
||||
return Double(visitedStadiums) / Double(totalStadiums)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Conference Progress
|
||||
|
||||
/// Progress tracking for a conference
|
||||
struct ConferenceProgress: Identifiable {
|
||||
let conference: Conference
|
||||
let totalStadiums: Int
|
||||
let visitedStadiums: Int
|
||||
let divisionProgress: [DivisionProgress]
|
||||
|
||||
var id: String { conference.id }
|
||||
|
||||
var completionPercentage: Double {
|
||||
guard totalStadiums > 0 else { return 0 }
|
||||
return Double(visitedStadiums) / Double(totalStadiums) * 100
|
||||
}
|
||||
|
||||
var isComplete: Bool {
|
||||
totalStadiums > 0 && visitedStadiums >= totalStadiums
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Overall Progress
|
||||
|
||||
/// Combined progress across all sports
|
||||
struct OverallProgress {
|
||||
let leagueProgress: [LeagueProgress]
|
||||
let totalVisits: Int
|
||||
let uniqueStadiumsVisited: Int
|
||||
let totalStadiumsAcrossLeagues: Int
|
||||
let achievementsEarned: Int
|
||||
let totalAchievements: Int
|
||||
|
||||
var overallPercentage: Double {
|
||||
guard totalStadiumsAcrossLeagues > 0 else { return 0 }
|
||||
return Double(uniqueStadiumsVisited) / Double(totalStadiumsAcrossLeagues) * 100
|
||||
}
|
||||
|
||||
/// Progress by sport
|
||||
func progress(for sport: Sport) -> LeagueProgress? {
|
||||
leagueProgress.first { $0.sport == sport }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Visit Summary
|
||||
|
||||
/// Summary of a stadium visit for display
|
||||
struct VisitSummary: Identifiable {
|
||||
let id: UUID
|
||||
let stadium: Stadium
|
||||
let visitDate: Date
|
||||
let visitType: VisitType
|
||||
let sport: Sport
|
||||
let matchup: String?
|
||||
let score: String?
|
||||
let photoCount: Int
|
||||
let notes: String?
|
||||
|
||||
var dateDescription: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
return formatter.string(from: visitDate)
|
||||
}
|
||||
|
||||
var shortDateDescription: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d, yyyy"
|
||||
return formatter.string(from: visitDate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stadium Visit Status
|
||||
|
||||
/// Status of a stadium (visited or not, with visit info if applicable)
|
||||
enum StadiumVisitStatus {
|
||||
case visited(visits: [VisitSummary])
|
||||
case notVisited
|
||||
|
||||
var isVisited: Bool {
|
||||
if case .visited = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
var visitCount: Int {
|
||||
if case .visited(let visits) = self {
|
||||
return visits.count
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
var latestVisit: VisitSummary? {
|
||||
if case .visited(let visits) = self {
|
||||
return visits.max(by: { $0.visitDate < $1.visitDate })
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var firstVisit: VisitSummary? {
|
||||
if case .visited(let visits) = self {
|
||||
return visits.min(by: { $0.visitDate < $1.visitDate })
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress Card Data
|
||||
|
||||
/// Data for generating shareable progress cards
|
||||
struct ProgressCardData {
|
||||
let sport: Sport
|
||||
let progress: LeagueProgress
|
||||
let username: String?
|
||||
let includeMap: Bool
|
||||
let showDetailedStats: Bool
|
||||
|
||||
var title: String {
|
||||
"\(sport.displayName) Stadium Quest"
|
||||
}
|
||||
|
||||
var subtitle: String {
|
||||
"\(progress.visitedStadiums) of \(progress.totalStadiums) Stadiums"
|
||||
}
|
||||
|
||||
var percentageText: String {
|
||||
String(format: "%.0f%%", progress.completionPercentage)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress Card Options
|
||||
|
||||
struct ProgressCardOptions {
|
||||
var includeUsername: Bool = true
|
||||
var username: String?
|
||||
var includeMapSnapshot: Bool = true
|
||||
var includeStats: Bool = true
|
||||
var cardStyle: CardStyle = .dark
|
||||
|
||||
enum CardStyle {
|
||||
case dark
|
||||
case light
|
||||
|
||||
var backgroundColor: Color {
|
||||
switch self {
|
||||
case .dark: return Color(hex: "1A1A2E")
|
||||
case .light: return Color.white
|
||||
}
|
||||
}
|
||||
|
||||
var textColor: Color {
|
||||
switch self {
|
||||
case .dark: return .white
|
||||
case .light: return Color(hex: "1A1A2E")
|
||||
}
|
||||
}
|
||||
|
||||
var secondaryTextColor: Color {
|
||||
switch self {
|
||||
case .dark: return Color(hex: "B8B8D1")
|
||||
case .light: return Color(hex: "666666")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ struct Stadium: Identifiable, Codable, Hashable {
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let capacity: Int
|
||||
let sport: Sport
|
||||
let yearOpened: Int?
|
||||
let imageURL: URL?
|
||||
|
||||
@@ -25,6 +26,7 @@ struct Stadium: Identifiable, Codable, Hashable {
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
capacity: Int,
|
||||
sport: Sport,
|
||||
yearOpened: Int? = nil,
|
||||
imageURL: URL? = nil
|
||||
) {
|
||||
@@ -35,6 +37,7 @@ struct Stadium: Identifiable, Codable, Hashable {
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.capacity = capacity
|
||||
self.sport = sport
|
||||
self.yearOpened = yearOpened
|
||||
self.imageURL = imageURL
|
||||
}
|
||||
|
||||
@@ -58,7 +58,15 @@ struct Trip: Identifiable, Codable, Hashable {
|
||||
return totalDrivingHours / Double(tripDuration)
|
||||
}
|
||||
|
||||
var cities: [String] { stops.map { $0.city } }
|
||||
var cities: [String] {
|
||||
// Deduplicate while preserving order
|
||||
var seen: Set<String> = []
|
||||
return stops.compactMap { stop in
|
||||
guard !seen.contains(stop.city) else { return nil }
|
||||
seen.insert(stop.city)
|
||||
return stop.city
|
||||
}
|
||||
}
|
||||
var uniqueSports: Set<Sport> { preferences.sports }
|
||||
var startDate: Date { stops.first?.arrivalDate ?? preferences.startDate }
|
||||
var endDate: Date { stops.last?.departureDate ?? preferences.endDate }
|
||||
|
||||
492
SportsTime/Core/Models/Local/CanonicalModels.swift
Normal file
492
SportsTime/Core/Models/Local/CanonicalModels.swift
Normal file
@@ -0,0 +1,492 @@
|
||||
//
|
||||
// CanonicalModels.swift
|
||||
// SportsTime
|
||||
//
|
||||
// SwiftData models for canonical data: stadiums, teams, games, and league structure.
|
||||
// These are the runtime source of truth, populated from bundled JSON and synced via CloudKit.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import CryptoKit
|
||||
|
||||
// MARK: - Schema Version
|
||||
|
||||
/// Schema version constants for canonical data models.
|
||||
/// Marked nonisolated to allow access from any isolation domain.
|
||||
nonisolated enum SchemaVersion {
|
||||
static let current: Int = 1
|
||||
static let minimumSupported: Int = 1
|
||||
}
|
||||
|
||||
// MARK: - Data Source
|
||||
|
||||
enum DataSource: String, Codable {
|
||||
case bundled // Shipped with app bundle
|
||||
case cloudKit // Synced from CloudKit
|
||||
case userCorrection // User-provided correction
|
||||
}
|
||||
|
||||
// MARK: - Team Alias Type
|
||||
|
||||
enum TeamAliasType: String, Codable {
|
||||
case abbreviation // Old abbreviation (e.g., "NJN" -> "BRK")
|
||||
case name // Old team name (e.g., "New Jersey Nets")
|
||||
case city // Old city (e.g., "New Jersey")
|
||||
}
|
||||
|
||||
// MARK: - League Structure Type
|
||||
|
||||
enum LeagueStructureType: String, Codable {
|
||||
case conference
|
||||
case division
|
||||
case league
|
||||
}
|
||||
|
||||
// MARK: - Sync State
|
||||
|
||||
@Model
|
||||
final class SyncState {
|
||||
@Attribute(.unique) var id: String = "singleton"
|
||||
|
||||
// Bootstrap tracking
|
||||
var bootstrapCompleted: Bool = false
|
||||
var bundledSchemaVersion: Int = 0
|
||||
var lastBootstrap: Date?
|
||||
|
||||
// CloudKit sync tracking
|
||||
var lastSuccessfulSync: Date?
|
||||
var lastSyncAttempt: Date?
|
||||
var lastSyncError: String?
|
||||
var syncInProgress: Bool = false
|
||||
var syncEnabled: Bool = true
|
||||
var syncPausedReason: String?
|
||||
var consecutiveFailures: Int = 0
|
||||
|
||||
// Change tokens for delta sync
|
||||
var stadiumChangeToken: Data?
|
||||
var teamChangeToken: Data?
|
||||
var gameChangeToken: Data?
|
||||
var leagueChangeToken: Data?
|
||||
|
||||
init() {}
|
||||
|
||||
static func current(in context: ModelContext) -> SyncState {
|
||||
let descriptor = FetchDescriptor<SyncState>(
|
||||
predicate: #Predicate { $0.id == "singleton" }
|
||||
)
|
||||
if let existing = try? context.fetch(descriptor).first {
|
||||
return existing
|
||||
}
|
||||
let new = SyncState()
|
||||
context.insert(new)
|
||||
return new
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Canonical Stadium
|
||||
|
||||
@Model
|
||||
final class CanonicalStadium {
|
||||
// Identity
|
||||
@Attribute(.unique) var canonicalId: String
|
||||
var uuid: UUID
|
||||
|
||||
// Versioning
|
||||
var schemaVersion: Int
|
||||
var lastModified: Date
|
||||
var sourceRaw: String
|
||||
|
||||
// Deprecation (soft delete)
|
||||
var deprecatedAt: Date?
|
||||
var deprecationReason: String?
|
||||
var replacedByCanonicalId: String?
|
||||
|
||||
// Core data
|
||||
var name: String
|
||||
var city: String
|
||||
var state: String
|
||||
var latitude: Double
|
||||
var longitude: Double
|
||||
var capacity: Int
|
||||
var yearOpened: Int?
|
||||
var imageURL: String?
|
||||
var sport: String
|
||||
|
||||
// User-correctable fields (preserved during sync)
|
||||
var userNickname: String?
|
||||
var userNotes: String?
|
||||
var isFavorite: Bool = false
|
||||
|
||||
// Relationships
|
||||
@Relationship(deleteRule: .cascade, inverse: \StadiumAlias.stadium)
|
||||
var aliases: [StadiumAlias]?
|
||||
|
||||
init(
|
||||
canonicalId: String,
|
||||
uuid: UUID? = nil,
|
||||
schemaVersion: Int = SchemaVersion.current,
|
||||
lastModified: Date = Date(),
|
||||
source: DataSource = .bundled,
|
||||
name: String,
|
||||
city: String,
|
||||
state: String,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
capacity: Int,
|
||||
yearOpened: Int? = nil,
|
||||
imageURL: String? = nil,
|
||||
sport: String
|
||||
) {
|
||||
self.canonicalId = canonicalId
|
||||
self.uuid = uuid ?? Self.deterministicUUID(from: canonicalId)
|
||||
self.schemaVersion = schemaVersion
|
||||
self.lastModified = lastModified
|
||||
self.sourceRaw = source.rawValue
|
||||
self.name = name
|
||||
self.city = city
|
||||
self.state = state
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.capacity = capacity
|
||||
self.yearOpened = yearOpened
|
||||
self.imageURL = imageURL
|
||||
self.sport = sport
|
||||
}
|
||||
|
||||
var source: DataSource {
|
||||
get { DataSource(rawValue: sourceRaw) ?? .bundled }
|
||||
set { sourceRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var isActive: Bool { deprecatedAt == nil }
|
||||
|
||||
func toDomain() -> Stadium {
|
||||
Stadium(
|
||||
id: uuid,
|
||||
name: name,
|
||||
city: city,
|
||||
state: state,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
capacity: capacity,
|
||||
sport: Sport(rawValue: sport) ?? .mlb,
|
||||
yearOpened: yearOpened,
|
||||
imageURL: imageURL.flatMap { URL(string: $0) }
|
||||
)
|
||||
}
|
||||
|
||||
static func deterministicUUID(from string: String) -> UUID {
|
||||
let data = Data(string.utf8)
|
||||
let hash = SHA256.hash(data: data)
|
||||
let hashBytes = Array(hash)
|
||||
var bytes = Array(hashBytes.prefix(16))
|
||||
// Set version 4 and variant bits
|
||||
bytes[6] = (bytes[6] & 0x0F) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3F) | 0x80
|
||||
return UUID(uuid: (
|
||||
bytes[0], bytes[1], bytes[2], bytes[3],
|
||||
bytes[4], bytes[5], bytes[6], bytes[7],
|
||||
bytes[8], bytes[9], bytes[10], bytes[11],
|
||||
bytes[12], bytes[13], bytes[14], bytes[15]
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stadium Alias
|
||||
|
||||
@Model
|
||||
final class StadiumAlias {
|
||||
@Attribute(.unique) var aliasName: String
|
||||
var stadiumCanonicalId: String
|
||||
var validFrom: Date?
|
||||
var validUntil: Date?
|
||||
var schemaVersion: Int
|
||||
var lastModified: Date
|
||||
|
||||
var stadium: CanonicalStadium?
|
||||
|
||||
init(
|
||||
aliasName: String,
|
||||
stadiumCanonicalId: String,
|
||||
validFrom: Date? = nil,
|
||||
validUntil: Date? = nil,
|
||||
schemaVersion: Int = SchemaVersion.current,
|
||||
lastModified: Date = Date()
|
||||
) {
|
||||
self.aliasName = aliasName.lowercased()
|
||||
self.stadiumCanonicalId = stadiumCanonicalId
|
||||
self.validFrom = validFrom
|
||||
self.validUntil = validUntil
|
||||
self.schemaVersion = schemaVersion
|
||||
self.lastModified = lastModified
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Canonical Team
|
||||
|
||||
@Model
|
||||
final class CanonicalTeam {
|
||||
@Attribute(.unique) var canonicalId: String
|
||||
var uuid: UUID
|
||||
|
||||
var schemaVersion: Int
|
||||
var lastModified: Date
|
||||
var sourceRaw: String
|
||||
|
||||
var deprecatedAt: Date?
|
||||
var deprecationReason: String?
|
||||
var relocatedToCanonicalId: String?
|
||||
|
||||
var name: String
|
||||
var abbreviation: String
|
||||
var sport: String
|
||||
var city: String
|
||||
var stadiumCanonicalId: String
|
||||
var logoURL: String?
|
||||
var primaryColor: String?
|
||||
var secondaryColor: String?
|
||||
var conferenceId: String?
|
||||
var divisionId: String?
|
||||
|
||||
// User-correctable
|
||||
var userNickname: String?
|
||||
var isFavorite: Bool = false
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \TeamAlias.team)
|
||||
var aliases: [TeamAlias]?
|
||||
|
||||
init(
|
||||
canonicalId: String,
|
||||
uuid: UUID? = nil,
|
||||
schemaVersion: Int = SchemaVersion.current,
|
||||
lastModified: Date = Date(),
|
||||
source: DataSource = .bundled,
|
||||
name: String,
|
||||
abbreviation: String,
|
||||
sport: String,
|
||||
city: String,
|
||||
stadiumCanonicalId: String,
|
||||
logoURL: String? = nil,
|
||||
primaryColor: String? = nil,
|
||||
secondaryColor: String? = nil,
|
||||
conferenceId: String? = nil,
|
||||
divisionId: String? = nil
|
||||
) {
|
||||
self.canonicalId = canonicalId
|
||||
self.uuid = uuid ?? CanonicalStadium.deterministicUUID(from: canonicalId)
|
||||
self.schemaVersion = schemaVersion
|
||||
self.lastModified = lastModified
|
||||
self.sourceRaw = source.rawValue
|
||||
self.name = name
|
||||
self.abbreviation = abbreviation
|
||||
self.sport = sport
|
||||
self.city = city
|
||||
self.stadiumCanonicalId = stadiumCanonicalId
|
||||
self.logoURL = logoURL
|
||||
self.primaryColor = primaryColor
|
||||
self.secondaryColor = secondaryColor
|
||||
self.conferenceId = conferenceId
|
||||
self.divisionId = divisionId
|
||||
}
|
||||
|
||||
var source: DataSource {
|
||||
get { DataSource(rawValue: sourceRaw) ?? .bundled }
|
||||
set { sourceRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var isActive: Bool { deprecatedAt == nil }
|
||||
|
||||
var sportEnum: Sport? { Sport(rawValue: sport) }
|
||||
|
||||
func toDomain(stadiumUUID: UUID) -> Team {
|
||||
Team(
|
||||
id: uuid,
|
||||
name: name,
|
||||
abbreviation: abbreviation,
|
||||
sport: sportEnum ?? .mlb,
|
||||
city: city,
|
||||
stadiumId: stadiumUUID,
|
||||
logoURL: logoURL.flatMap { URL(string: $0) },
|
||||
primaryColor: primaryColor,
|
||||
secondaryColor: secondaryColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Team Alias
|
||||
|
||||
@Model
|
||||
final class TeamAlias {
|
||||
@Attribute(.unique) var id: String
|
||||
var teamCanonicalId: String
|
||||
var aliasTypeRaw: String
|
||||
var aliasValue: String
|
||||
var validFrom: Date?
|
||||
var validUntil: Date?
|
||||
var schemaVersion: Int
|
||||
var lastModified: Date
|
||||
|
||||
var team: CanonicalTeam?
|
||||
|
||||
init(
|
||||
id: String,
|
||||
teamCanonicalId: String,
|
||||
aliasType: TeamAliasType,
|
||||
aliasValue: String,
|
||||
validFrom: Date? = nil,
|
||||
validUntil: Date? = nil,
|
||||
schemaVersion: Int = SchemaVersion.current,
|
||||
lastModified: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.teamCanonicalId = teamCanonicalId
|
||||
self.aliasTypeRaw = aliasType.rawValue
|
||||
self.aliasValue = aliasValue
|
||||
self.validFrom = validFrom
|
||||
self.validUntil = validUntil
|
||||
self.schemaVersion = schemaVersion
|
||||
self.lastModified = lastModified
|
||||
}
|
||||
|
||||
var aliasType: TeamAliasType {
|
||||
get { TeamAliasType(rawValue: aliasTypeRaw) ?? .name }
|
||||
set { aliasTypeRaw = newValue.rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - League Structure
|
||||
|
||||
@Model
|
||||
final class LeagueStructureModel {
|
||||
@Attribute(.unique) var id: String
|
||||
var sport: String
|
||||
var structureTypeRaw: String
|
||||
var name: String
|
||||
var abbreviation: String?
|
||||
var parentId: String?
|
||||
var displayOrder: Int
|
||||
var schemaVersion: Int
|
||||
var lastModified: Date
|
||||
|
||||
init(
|
||||
id: String,
|
||||
sport: String,
|
||||
structureType: LeagueStructureType,
|
||||
name: String,
|
||||
abbreviation: String? = nil,
|
||||
parentId: String? = nil,
|
||||
displayOrder: Int = 0,
|
||||
schemaVersion: Int = SchemaVersion.current,
|
||||
lastModified: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.sport = sport
|
||||
self.structureTypeRaw = structureType.rawValue
|
||||
self.name = name
|
||||
self.abbreviation = abbreviation
|
||||
self.parentId = parentId
|
||||
self.displayOrder = displayOrder
|
||||
self.schemaVersion = schemaVersion
|
||||
self.lastModified = lastModified
|
||||
}
|
||||
|
||||
var structureType: LeagueStructureType {
|
||||
get { LeagueStructureType(rawValue: structureTypeRaw) ?? .division }
|
||||
set { structureTypeRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var sportEnum: Sport? { Sport(rawValue: sport) }
|
||||
}
|
||||
|
||||
// MARK: - Canonical Game
|
||||
|
||||
@Model
|
||||
final class CanonicalGame {
|
||||
@Attribute(.unique) var canonicalId: String
|
||||
var uuid: UUID
|
||||
|
||||
var schemaVersion: Int
|
||||
var lastModified: Date
|
||||
var sourceRaw: String
|
||||
|
||||
var deprecatedAt: Date?
|
||||
var deprecationReason: String?
|
||||
var rescheduledToCanonicalId: String?
|
||||
|
||||
var homeTeamCanonicalId: String
|
||||
var awayTeamCanonicalId: String
|
||||
var stadiumCanonicalId: String
|
||||
var dateTime: Date
|
||||
var sport: String
|
||||
var season: String
|
||||
var isPlayoff: Bool
|
||||
var broadcastInfo: String?
|
||||
|
||||
// User-correctable
|
||||
var userAttending: Bool = false
|
||||
var userNotes: String?
|
||||
|
||||
init(
|
||||
canonicalId: String,
|
||||
uuid: UUID? = nil,
|
||||
schemaVersion: Int = SchemaVersion.current,
|
||||
lastModified: Date = Date(),
|
||||
source: DataSource = .bundled,
|
||||
homeTeamCanonicalId: String,
|
||||
awayTeamCanonicalId: String,
|
||||
stadiumCanonicalId: String,
|
||||
dateTime: Date,
|
||||
sport: String,
|
||||
season: String,
|
||||
isPlayoff: Bool = false,
|
||||
broadcastInfo: String? = nil
|
||||
) {
|
||||
self.canonicalId = canonicalId
|
||||
self.uuid = uuid ?? CanonicalStadium.deterministicUUID(from: canonicalId)
|
||||
self.schemaVersion = schemaVersion
|
||||
self.lastModified = lastModified
|
||||
self.sourceRaw = source.rawValue
|
||||
self.homeTeamCanonicalId = homeTeamCanonicalId
|
||||
self.awayTeamCanonicalId = awayTeamCanonicalId
|
||||
self.stadiumCanonicalId = stadiumCanonicalId
|
||||
self.dateTime = dateTime
|
||||
self.sport = sport
|
||||
self.season = season
|
||||
self.isPlayoff = isPlayoff
|
||||
self.broadcastInfo = broadcastInfo
|
||||
}
|
||||
|
||||
var source: DataSource {
|
||||
get { DataSource(rawValue: sourceRaw) ?? .bundled }
|
||||
set { sourceRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var isActive: Bool { deprecatedAt == nil }
|
||||
|
||||
var sportEnum: Sport? { Sport(rawValue: sport) }
|
||||
|
||||
func toDomain(homeTeamUUID: UUID, awayTeamUUID: UUID, stadiumUUID: UUID) -> Game {
|
||||
Game(
|
||||
id: uuid,
|
||||
homeTeamId: homeTeamUUID,
|
||||
awayTeamId: awayTeamUUID,
|
||||
stadiumId: stadiumUUID,
|
||||
dateTime: dateTime,
|
||||
sport: sportEnum ?? .mlb,
|
||||
season: season,
|
||||
isPlayoff: isPlayoff,
|
||||
broadcastInfo: broadcastInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bundled Data Timestamps
|
||||
|
||||
/// Timestamps for bundled data files.
|
||||
/// Marked nonisolated to allow access from any isolation domain.
|
||||
nonisolated enum BundledDataTimestamp {
|
||||
static let stadiums = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date()
|
||||
static let games = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date()
|
||||
static let leagueStructure = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date()
|
||||
}
|
||||
364
SportsTime/Core/Models/Local/StadiumProgress.swift
Normal file
364
SportsTime/Core/Models/Local/StadiumProgress.swift
Normal file
@@ -0,0 +1,364 @@
|
||||
//
|
||||
// StadiumProgress.swift
|
||||
// SportsTime
|
||||
//
|
||||
// SwiftData models for tracking stadium visits, achievements, and photo metadata.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Visit Type
|
||||
|
||||
enum VisitType: String, Codable, CaseIterable {
|
||||
case game // Attended a game
|
||||
case tour // Stadium tour without game
|
||||
case other // Other visit (tailgating, event, etc.)
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .game: return "Game"
|
||||
case .tour: return "Tour"
|
||||
case .other: return "Other"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Source Type
|
||||
|
||||
enum VisitDataSource: String, Codable {
|
||||
case automatic // All data from photo + API
|
||||
case partialManual // Photo metadata + manual game selection
|
||||
case fullyManual // User entered everything
|
||||
case userCorrected // Was automatic, user edited
|
||||
}
|
||||
|
||||
// MARK: - Score Source Type
|
||||
|
||||
enum ScoreSource: String, Codable {
|
||||
case app // From app's schedule data
|
||||
case api // From free sports API
|
||||
case scraped // From reference site scraping
|
||||
case user // User-provided
|
||||
}
|
||||
|
||||
// MARK: - Visit Source Type
|
||||
|
||||
enum VisitSource: String, Codable {
|
||||
case trip // Created from a planned trip
|
||||
case manual // Manually entered
|
||||
case photoImport // Imported from photo library
|
||||
}
|
||||
|
||||
// MARK: - Upload Status
|
||||
|
||||
enum UploadStatus: String, Codable {
|
||||
case pending
|
||||
case uploaded
|
||||
case failed
|
||||
}
|
||||
|
||||
// MARK: - Stadium Visit
|
||||
|
||||
@Model
|
||||
final class StadiumVisit {
|
||||
@Attribute(.unique) var id: UUID
|
||||
|
||||
// Stadium identity (stable across renames)
|
||||
var canonicalStadiumId: String // Links to CanonicalStadium.canonicalId
|
||||
var stadiumUUID: UUID // Runtime UUID for display lookups
|
||||
var stadiumNameAtVisit: String // Frozen at visit time
|
||||
|
||||
// Visit details
|
||||
var visitDate: Date
|
||||
var sport: String // Sport.rawValue
|
||||
var visitTypeRaw: String // VisitType.rawValue
|
||||
|
||||
// Game info (optional - nil for tours/other visits)
|
||||
var gameId: UUID?
|
||||
var homeTeamId: UUID?
|
||||
var awayTeamId: UUID?
|
||||
var homeTeamName: String? // For display when team lookup fails
|
||||
var awayTeamName: String?
|
||||
var finalScore: String? // "5-3" format
|
||||
var manualGameDescription: String? // User's description if game not found
|
||||
|
||||
// Resolution tracking
|
||||
var scoreSourceRaw: String? // ScoreSource.rawValue
|
||||
var dataSourceRaw: String // VisitDataSource.rawValue
|
||||
var scoreResolutionPending: Bool // true if background retry needed
|
||||
|
||||
// User data
|
||||
var seatLocation: String?
|
||||
var notes: String?
|
||||
|
||||
// Photos
|
||||
@Relationship(deleteRule: .cascade, inverse: \VisitPhotoMetadata.visit)
|
||||
var photoMetadata: [VisitPhotoMetadata]?
|
||||
|
||||
// Photo import metadata (preserved for debugging/re-matching)
|
||||
var photoLatitude: Double?
|
||||
var photoLongitude: Double?
|
||||
var photoCaptureDate: Date?
|
||||
|
||||
// Audit
|
||||
var createdAt: Date
|
||||
var sourceRaw: String // VisitSource.rawValue
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
canonicalStadiumId: String,
|
||||
stadiumUUID: UUID,
|
||||
stadiumNameAtVisit: String,
|
||||
visitDate: Date,
|
||||
sport: Sport,
|
||||
visitType: VisitType = .game,
|
||||
gameId: UUID? = nil,
|
||||
homeTeamId: UUID? = nil,
|
||||
awayTeamId: UUID? = nil,
|
||||
homeTeamName: String? = nil,
|
||||
awayTeamName: String? = nil,
|
||||
finalScore: String? = nil,
|
||||
manualGameDescription: String? = nil,
|
||||
scoreSource: ScoreSource? = nil,
|
||||
dataSource: VisitDataSource = .fullyManual,
|
||||
scoreResolutionPending: Bool = false,
|
||||
seatLocation: String? = nil,
|
||||
notes: String? = nil,
|
||||
photoLatitude: Double? = nil,
|
||||
photoLongitude: Double? = nil,
|
||||
photoCaptureDate: Date? = nil,
|
||||
source: VisitSource = .manual
|
||||
) {
|
||||
self.id = id
|
||||
self.canonicalStadiumId = canonicalStadiumId
|
||||
self.stadiumUUID = stadiumUUID
|
||||
self.stadiumNameAtVisit = stadiumNameAtVisit
|
||||
self.visitDate = visitDate
|
||||
self.sport = sport.rawValue
|
||||
self.visitTypeRaw = visitType.rawValue
|
||||
self.gameId = gameId
|
||||
self.homeTeamId = homeTeamId
|
||||
self.awayTeamId = awayTeamId
|
||||
self.homeTeamName = homeTeamName
|
||||
self.awayTeamName = awayTeamName
|
||||
self.finalScore = finalScore
|
||||
self.manualGameDescription = manualGameDescription
|
||||
self.scoreSourceRaw = scoreSource?.rawValue
|
||||
self.dataSourceRaw = dataSource.rawValue
|
||||
self.scoreResolutionPending = scoreResolutionPending
|
||||
self.seatLocation = seatLocation
|
||||
self.notes = notes
|
||||
self.photoLatitude = photoLatitude
|
||||
self.photoLongitude = photoLongitude
|
||||
self.photoCaptureDate = photoCaptureDate
|
||||
self.createdAt = Date()
|
||||
self.sourceRaw = source.rawValue
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var visitType: VisitType {
|
||||
get { VisitType(rawValue: visitTypeRaw) ?? .game }
|
||||
set { visitTypeRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var scoreSource: ScoreSource? {
|
||||
get { scoreSourceRaw.flatMap { ScoreSource(rawValue: $0) } }
|
||||
set { scoreSourceRaw = newValue?.rawValue }
|
||||
}
|
||||
|
||||
var dataSource: VisitDataSource {
|
||||
get { VisitDataSource(rawValue: dataSourceRaw) ?? .fullyManual }
|
||||
set { dataSourceRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var source: VisitSource {
|
||||
get { VisitSource(rawValue: sourceRaw) ?? .manual }
|
||||
set { sourceRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var sportEnum: Sport? {
|
||||
Sport(rawValue: sport)
|
||||
}
|
||||
|
||||
/// Display string for the game matchup
|
||||
var matchupDescription: String? {
|
||||
if let home = homeTeamName, let away = awayTeamName {
|
||||
return "\(away) @ \(home)"
|
||||
}
|
||||
return manualGameDescription
|
||||
}
|
||||
|
||||
/// Display string including score if available
|
||||
var matchupWithScore: String? {
|
||||
guard let matchup = matchupDescription else { return nil }
|
||||
if let score = finalScore {
|
||||
return "\(matchup) (\(score))"
|
||||
}
|
||||
return matchup
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Visit Photo Metadata
|
||||
|
||||
@Model
|
||||
final class VisitPhotoMetadata {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var visitId: UUID
|
||||
var cloudKitAssetId: String? // Set after successful upload
|
||||
var thumbnailData: Data? // 200x200 JPEG for fast loading
|
||||
var caption: String?
|
||||
var orderIndex: Int
|
||||
var uploadStatusRaw: String // UploadStatus.rawValue
|
||||
var createdAt: Date
|
||||
|
||||
var visit: StadiumVisit?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
visitId: UUID,
|
||||
cloudKitAssetId: String? = nil,
|
||||
thumbnailData: Data? = nil,
|
||||
caption: String? = nil,
|
||||
orderIndex: Int = 0,
|
||||
uploadStatus: UploadStatus = .pending
|
||||
) {
|
||||
self.id = id
|
||||
self.visitId = visitId
|
||||
self.cloudKitAssetId = cloudKitAssetId
|
||||
self.thumbnailData = thumbnailData
|
||||
self.caption = caption
|
||||
self.orderIndex = orderIndex
|
||||
self.uploadStatusRaw = uploadStatus.rawValue
|
||||
self.createdAt = Date()
|
||||
}
|
||||
|
||||
var uploadStatus: UploadStatus {
|
||||
get { UploadStatus(rawValue: uploadStatusRaw) ?? .pending }
|
||||
set { uploadStatusRaw = newValue.rawValue }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievement
|
||||
|
||||
@Model
|
||||
final class Achievement {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var achievementTypeId: String // e.g., "mlb_all_30", "nl_west_complete"
|
||||
var sport: String? // Sport.rawValue, nil for cross-sport achievements
|
||||
var earnedAt: Date
|
||||
var revokedAt: Date? // Non-nil if visits deleted
|
||||
var visitIdsSnapshot: Data // JSON-encoded [UUID] that earned this
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
achievementTypeId: String,
|
||||
sport: Sport? = nil,
|
||||
earnedAt: Date = Date(),
|
||||
visitIds: [UUID]
|
||||
) {
|
||||
self.id = id
|
||||
self.achievementTypeId = achievementTypeId
|
||||
self.sport = sport?.rawValue
|
||||
self.earnedAt = earnedAt
|
||||
self.revokedAt = nil
|
||||
self.visitIdsSnapshot = (try? JSONEncoder().encode(visitIds)) ?? Data()
|
||||
}
|
||||
|
||||
var sportEnum: Sport? {
|
||||
sport.flatMap { Sport(rawValue: $0) }
|
||||
}
|
||||
|
||||
var visitIds: [UUID] {
|
||||
(try? JSONDecoder().decode([UUID].self, from: visitIdsSnapshot)) ?? []
|
||||
}
|
||||
|
||||
var isEarned: Bool {
|
||||
revokedAt == nil
|
||||
}
|
||||
|
||||
func revoke() {
|
||||
revokedAt = Date()
|
||||
}
|
||||
|
||||
func restore() {
|
||||
revokedAt = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cached Game Score
|
||||
|
||||
/// Caches resolved game scores to avoid repeated API calls.
|
||||
/// Historical scores never change, so they can be cached indefinitely.
|
||||
@Model
|
||||
final class CachedGameScore {
|
||||
@Attribute(.unique) var cacheKey: String // "MLB_2010-06-15_SFG_LAD"
|
||||
var sport: String
|
||||
var gameDate: Date
|
||||
var homeTeamAbbrev: String
|
||||
var awayTeamAbbrev: String
|
||||
var homeTeamName: String
|
||||
var awayTeamName: String
|
||||
var homeScore: Int?
|
||||
var awayScore: Int?
|
||||
var sourceRaw: String // ScoreSource.rawValue
|
||||
var fetchedAt: Date
|
||||
var expiresAt: Date? // nil = never expires (historical data)
|
||||
|
||||
init(
|
||||
cacheKey: String,
|
||||
sport: Sport,
|
||||
gameDate: Date,
|
||||
homeTeamAbbrev: String,
|
||||
awayTeamAbbrev: String,
|
||||
homeTeamName: String,
|
||||
awayTeamName: String,
|
||||
homeScore: Int?,
|
||||
awayScore: Int?,
|
||||
source: ScoreSource,
|
||||
expiresAt: Date? = nil
|
||||
) {
|
||||
self.cacheKey = cacheKey
|
||||
self.sport = sport.rawValue
|
||||
self.gameDate = gameDate
|
||||
self.homeTeamAbbrev = homeTeamAbbrev
|
||||
self.awayTeamAbbrev = awayTeamAbbrev
|
||||
self.homeTeamName = homeTeamName
|
||||
self.awayTeamName = awayTeamName
|
||||
self.homeScore = homeScore
|
||||
self.awayScore = awayScore
|
||||
self.sourceRaw = source.rawValue
|
||||
self.fetchedAt = Date()
|
||||
self.expiresAt = expiresAt
|
||||
}
|
||||
|
||||
var scoreSource: ScoreSource {
|
||||
get { ScoreSource(rawValue: sourceRaw) ?? .api }
|
||||
set { sourceRaw = newValue.rawValue }
|
||||
}
|
||||
|
||||
var sportEnum: Sport? {
|
||||
Sport(rawValue: sport)
|
||||
}
|
||||
|
||||
var scoreString: String? {
|
||||
guard let home = homeScore, let away = awayScore else { return nil }
|
||||
return "\(away)-\(home)"
|
||||
}
|
||||
|
||||
var isExpired: Bool {
|
||||
guard let expiresAt = expiresAt else { return false }
|
||||
return Date() > expiresAt
|
||||
}
|
||||
|
||||
/// Generate cache key for a game query
|
||||
static func generateKey(sport: Sport, date: Date, homeAbbrev: String, awayAbbrev: String) -> String {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "yyyy-MM-dd"
|
||||
let dateString = dateFormatter.string(from: date)
|
||||
return "\(sport.rawValue)_\(dateString)_\(homeAbbrev)_\(awayAbbrev)"
|
||||
}
|
||||
}
|
||||
444
SportsTime/Core/Services/AchievementEngine.swift
Normal file
444
SportsTime/Core/Services/AchievementEngine.swift
Normal file
@@ -0,0 +1,444 @@
|
||||
//
|
||||
// AchievementEngine.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Computes achievements based on stadium visits.
|
||||
// Recalculates and revokes achievements when visits are deleted.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Achievement Delta
|
||||
|
||||
struct AchievementDelta: Sendable {
|
||||
let newlyEarned: [AchievementDefinition]
|
||||
let revoked: [AchievementDefinition]
|
||||
let stillEarned: [AchievementDefinition]
|
||||
|
||||
var hasChanges: Bool {
|
||||
!newlyEarned.isEmpty || !revoked.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievement Engine
|
||||
|
||||
@MainActor
|
||||
final class AchievementEngine {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let modelContext: ModelContext
|
||||
private let dataProvider: AppDataProvider
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(modelContext: ModelContext, dataProvider: AppDataProvider = AppDataProvider.shared) {
|
||||
self.modelContext = modelContext
|
||||
self.dataProvider = dataProvider
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Full recalculation (call after visit deleted or on app update)
|
||||
func recalculateAllAchievements() async throws -> AchievementDelta {
|
||||
// Get all visits
|
||||
let visits = try fetchAllVisits()
|
||||
let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId })
|
||||
|
||||
// Get currently earned achievements
|
||||
let currentAchievements = try fetchEarnedAchievements()
|
||||
let currentAchievementIds = Set(currentAchievements.map { $0.achievementTypeId })
|
||||
|
||||
// Calculate which achievements should be earned
|
||||
var shouldBeEarned: Set<String> = []
|
||||
var newlyEarnedDefinitions: [AchievementDefinition] = []
|
||||
var revokedDefinitions: [AchievementDefinition] = []
|
||||
var stillEarnedDefinitions: [AchievementDefinition] = []
|
||||
|
||||
for definition in AchievementRegistry.all {
|
||||
let isEarned = checkRequirement(definition.requirement, visits: visits, visitedStadiumIds: visitedStadiumIds)
|
||||
|
||||
if isEarned {
|
||||
shouldBeEarned.insert(definition.id)
|
||||
|
||||
if currentAchievementIds.contains(definition.id) {
|
||||
stillEarnedDefinitions.append(definition)
|
||||
} else {
|
||||
newlyEarnedDefinitions.append(definition)
|
||||
}
|
||||
} else if currentAchievementIds.contains(definition.id) {
|
||||
revokedDefinitions.append(definition)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply changes
|
||||
// Grant new achievements
|
||||
for definition in newlyEarnedDefinitions {
|
||||
let visitIds = getContributingVisitIds(for: definition.requirement, visits: visits)
|
||||
let achievement = Achievement(
|
||||
achievementTypeId: definition.id,
|
||||
sport: definition.sport,
|
||||
visitIds: visitIds
|
||||
)
|
||||
modelContext.insert(achievement)
|
||||
}
|
||||
|
||||
// Revoke achievements
|
||||
for definition in revokedDefinitions {
|
||||
if let achievement = currentAchievements.first(where: { $0.achievementTypeId == definition.id }) {
|
||||
achievement.revoke()
|
||||
}
|
||||
}
|
||||
|
||||
// Restore previously revoked achievements that are now earned again
|
||||
for definition in stillEarnedDefinitions {
|
||||
if let achievement = currentAchievements.first(where: {
|
||||
$0.achievementTypeId == definition.id && $0.revokedAt != nil
|
||||
}) {
|
||||
achievement.restore()
|
||||
}
|
||||
}
|
||||
|
||||
try modelContext.save()
|
||||
|
||||
return AchievementDelta(
|
||||
newlyEarned: newlyEarnedDefinitions,
|
||||
revoked: revokedDefinitions,
|
||||
stillEarned: stillEarnedDefinitions
|
||||
)
|
||||
}
|
||||
|
||||
/// Quick check after new visit (incremental)
|
||||
func checkAchievementsForNewVisit(_ visit: StadiumVisit) async throws -> [AchievementDefinition] {
|
||||
let visits = try fetchAllVisits()
|
||||
let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId })
|
||||
|
||||
let currentAchievements = try fetchEarnedAchievements()
|
||||
let currentAchievementIds = Set(currentAchievements.map { $0.achievementTypeId })
|
||||
|
||||
var newlyEarned: [AchievementDefinition] = []
|
||||
|
||||
for definition in AchievementRegistry.all {
|
||||
// Skip already earned
|
||||
guard !currentAchievementIds.contains(definition.id) else { continue }
|
||||
|
||||
let isEarned = checkRequirement(definition.requirement, visits: visits, visitedStadiumIds: visitedStadiumIds)
|
||||
|
||||
if isEarned {
|
||||
newlyEarned.append(definition)
|
||||
|
||||
let visitIds = getContributingVisitIds(for: definition.requirement, visits: visits)
|
||||
let achievement = Achievement(
|
||||
achievementTypeId: definition.id,
|
||||
sport: definition.sport,
|
||||
visitIds: visitIds
|
||||
)
|
||||
modelContext.insert(achievement)
|
||||
}
|
||||
}
|
||||
|
||||
try modelContext.save()
|
||||
|
||||
return newlyEarned
|
||||
}
|
||||
|
||||
/// Get all earned achievements
|
||||
func getEarnedAchievements() throws -> [AchievementDefinition] {
|
||||
let achievements = try fetchEarnedAchievements()
|
||||
return achievements.compactMap { AchievementRegistry.achievement(byId: $0.achievementTypeId) }
|
||||
}
|
||||
|
||||
/// Get progress toward all achievements
|
||||
func getProgress() async throws -> [AchievementProgress] {
|
||||
let visits = try fetchAllVisits()
|
||||
let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId })
|
||||
let earnedAchievements = try fetchEarnedAchievements()
|
||||
let earnedIds = Set(earnedAchievements.map { $0.achievementTypeId })
|
||||
|
||||
var progress: [AchievementProgress] = []
|
||||
|
||||
for definition in AchievementRegistry.all {
|
||||
let (current, total) = calculateProgress(
|
||||
for: definition.requirement,
|
||||
visits: visits,
|
||||
visitedStadiumIds: visitedStadiumIds
|
||||
)
|
||||
|
||||
let isEarned = earnedIds.contains(definition.id)
|
||||
let earnedAt = earnedAchievements.first(where: { $0.achievementTypeId == definition.id })?.earnedAt
|
||||
|
||||
progress.append(AchievementProgress(
|
||||
definition: definition,
|
||||
currentProgress: current,
|
||||
totalRequired: total,
|
||||
isEarned: isEarned,
|
||||
earnedAt: earnedAt
|
||||
))
|
||||
}
|
||||
|
||||
return progress
|
||||
}
|
||||
|
||||
// MARK: - Requirement Checking
|
||||
|
||||
private func checkRequirement(
|
||||
_ requirement: AchievementRequirement,
|
||||
visits: [StadiumVisit],
|
||||
visitedStadiumIds: Set<String>
|
||||
) -> Bool {
|
||||
switch requirement {
|
||||
case .firstVisit:
|
||||
return !visits.isEmpty
|
||||
|
||||
case .visitCount(let count):
|
||||
return visitedStadiumIds.count >= count
|
||||
|
||||
case .visitCountForSport(let count, let sport):
|
||||
let sportVisits = visits.filter { $0.sport == sport.rawValue }
|
||||
let sportStadiums = Set(sportVisits.map { $0.canonicalStadiumId })
|
||||
return sportStadiums.count >= count
|
||||
|
||||
case .completeDivision(let divisionId):
|
||||
return checkDivisionComplete(divisionId, visitedStadiumIds: visitedStadiumIds)
|
||||
|
||||
case .completeConference(let conferenceId):
|
||||
return checkConferenceComplete(conferenceId, visitedStadiumIds: visitedStadiumIds)
|
||||
|
||||
case .completeLeague(let sport):
|
||||
return checkLeagueComplete(sport, visitedStadiumIds: visitedStadiumIds)
|
||||
|
||||
case .visitsInDays(let visitCount, let days):
|
||||
return checkVisitsInDays(visits: visits, requiredVisits: visitCount, withinDays: days)
|
||||
|
||||
case .multipleLeagues(let leagueCount):
|
||||
return checkMultipleLeagues(visits: visits, requiredLeagues: leagueCount)
|
||||
|
||||
case .specificStadium(let stadiumId):
|
||||
return visitedStadiumIds.contains(stadiumId)
|
||||
}
|
||||
}
|
||||
|
||||
private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set<String>) -> Bool {
|
||||
guard let division = LeagueStructure.division(byId: divisionId) else { return false }
|
||||
|
||||
// Get stadium IDs for teams in this division
|
||||
let stadiumIds = getStadiumIdsForDivision(divisionId)
|
||||
guard !stadiumIds.isEmpty else { return false }
|
||||
|
||||
return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) }
|
||||
}
|
||||
|
||||
private func checkConferenceComplete(_ conferenceId: String, visitedStadiumIds: Set<String>) -> Bool {
|
||||
guard let conference = LeagueStructure.conference(byId: conferenceId) else { return false }
|
||||
|
||||
// Get stadium IDs for all teams in this conference
|
||||
let stadiumIds = getStadiumIdsForConference(conferenceId)
|
||||
guard !stadiumIds.isEmpty else { return false }
|
||||
|
||||
return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) }
|
||||
}
|
||||
|
||||
private func checkLeagueComplete(_ sport: Sport, visitedStadiumIds: Set<String>) -> Bool {
|
||||
let stadiumIds = getStadiumIdsForLeague(sport)
|
||||
guard !stadiumIds.isEmpty else { return false }
|
||||
|
||||
return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) }
|
||||
}
|
||||
|
||||
private func checkVisitsInDays(visits: [StadiumVisit], requiredVisits: Int, withinDays: Int) -> Bool {
|
||||
guard visits.count >= requiredVisits else { return false }
|
||||
|
||||
// Sort visits by date
|
||||
let sortedVisits = visits.sorted { $0.visitDate < $1.visitDate }
|
||||
|
||||
// Sliding window
|
||||
for i in 0...(sortedVisits.count - requiredVisits) {
|
||||
let windowStart = sortedVisits[i].visitDate
|
||||
let windowEnd = sortedVisits[i + requiredVisits - 1].visitDate
|
||||
|
||||
let daysDiff = Calendar.current.dateComponents([.day], from: windowStart, to: windowEnd).day ?? Int.max
|
||||
if daysDiff < withinDays {
|
||||
// Check unique stadiums in window
|
||||
let windowVisits = Array(sortedVisits[i..<(i + requiredVisits)])
|
||||
let uniqueStadiums = Set(windowVisits.map { $0.canonicalStadiumId })
|
||||
if uniqueStadiums.count >= requiredVisits {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
private func checkMultipleLeagues(visits: [StadiumVisit], requiredLeagues: Int) -> Bool {
|
||||
let leagues = Set(visits.compactMap { Sport(rawValue: $0.sport) })
|
||||
return leagues.count >= requiredLeagues
|
||||
}
|
||||
|
||||
// MARK: - Progress Calculation
|
||||
|
||||
private func calculateProgress(
|
||||
for requirement: AchievementRequirement,
|
||||
visits: [StadiumVisit],
|
||||
visitedStadiumIds: Set<String>
|
||||
) -> (current: Int, total: Int) {
|
||||
switch requirement {
|
||||
case .firstVisit:
|
||||
return (visits.isEmpty ? 0 : 1, 1)
|
||||
|
||||
case .visitCount(let count):
|
||||
return (visitedStadiumIds.count, count)
|
||||
|
||||
case .visitCountForSport(let count, let sport):
|
||||
let sportVisits = visits.filter { $0.sport == sport.rawValue }
|
||||
let sportStadiums = Set(sportVisits.map { $0.canonicalStadiumId })
|
||||
return (sportStadiums.count, count)
|
||||
|
||||
case .completeDivision(let divisionId):
|
||||
let stadiumIds = getStadiumIdsForDivision(divisionId)
|
||||
let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count
|
||||
return (visited, stadiumIds.count)
|
||||
|
||||
case .completeConference(let conferenceId):
|
||||
let stadiumIds = getStadiumIdsForConference(conferenceId)
|
||||
let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count
|
||||
return (visited, stadiumIds.count)
|
||||
|
||||
case .completeLeague(let sport):
|
||||
let stadiumIds = getStadiumIdsForLeague(sport)
|
||||
let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count
|
||||
return (visited, stadiumIds.count)
|
||||
|
||||
case .visitsInDays(let visitCount, _):
|
||||
// For journey achievements, show total unique stadiums vs required
|
||||
return (min(visitedStadiumIds.count, visitCount), visitCount)
|
||||
|
||||
case .multipleLeagues(let leagueCount):
|
||||
let leagues = Set(visits.compactMap { Sport(rawValue: $0.sport) })
|
||||
return (leagues.count, leagueCount)
|
||||
|
||||
case .specificStadium(let stadiumId):
|
||||
return (visitedStadiumIds.contains(stadiumId) ? 1 : 0, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Contributing Visits
|
||||
|
||||
private func getContributingVisitIds(for requirement: AchievementRequirement, visits: [StadiumVisit]) -> [UUID] {
|
||||
switch requirement {
|
||||
case .firstVisit:
|
||||
return visits.first.map { [$0.id] } ?? []
|
||||
|
||||
case .visitCount, .visitCountForSport, .multipleLeagues:
|
||||
// All visits contribute
|
||||
return visits.map { $0.id }
|
||||
|
||||
case .completeDivision(let divisionId):
|
||||
let stadiumIds = Set(getStadiumIdsForDivision(divisionId))
|
||||
return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id }
|
||||
|
||||
case .completeConference(let conferenceId):
|
||||
let stadiumIds = Set(getStadiumIdsForConference(conferenceId))
|
||||
return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id }
|
||||
|
||||
case .completeLeague(let sport):
|
||||
let stadiumIds = Set(getStadiumIdsForLeague(sport))
|
||||
return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id }
|
||||
|
||||
case .visitsInDays(let requiredVisits, let days):
|
||||
// Find the qualifying window of visits
|
||||
let sortedVisits = visits.sorted { $0.visitDate < $1.visitDate }
|
||||
for i in 0...(sortedVisits.count - requiredVisits) {
|
||||
let windowStart = sortedVisits[i].visitDate
|
||||
let windowEnd = sortedVisits[i + requiredVisits - 1].visitDate
|
||||
let daysDiff = Calendar.current.dateComponents([.day], from: windowStart, to: windowEnd).day ?? Int.max
|
||||
if daysDiff < days {
|
||||
return Array(sortedVisits[i..<(i + requiredVisits)]).map { $0.id }
|
||||
}
|
||||
}
|
||||
return []
|
||||
|
||||
case .specificStadium(let stadiumId):
|
||||
return visits.filter { $0.canonicalStadiumId == stadiumId }.map { $0.id }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stadium Lookups
|
||||
|
||||
private func getStadiumIdsForDivision(_ divisionId: String) -> [String] {
|
||||
// Get teams in division, then their stadiums
|
||||
let teams = dataProvider.teams.filter { team in
|
||||
// Match division by checking team's division assignment
|
||||
// This would normally come from CanonicalTeam.divisionId
|
||||
// For now, return based on division structure
|
||||
return false // Will be populated when division data is linked
|
||||
}
|
||||
|
||||
// For now, return hardcoded counts based on typical division sizes
|
||||
// This should be replaced with actual team-to-stadium mapping
|
||||
return []
|
||||
}
|
||||
|
||||
private func getStadiumIdsForConference(_ conferenceId: String) -> [String] {
|
||||
guard let conference = LeagueStructure.conference(byId: conferenceId) else { return [] }
|
||||
|
||||
var stadiumIds: [String] = []
|
||||
for divisionId in conference.divisionIds {
|
||||
stadiumIds.append(contentsOf: getStadiumIdsForDivision(divisionId))
|
||||
}
|
||||
return stadiumIds
|
||||
}
|
||||
|
||||
private func getStadiumIdsForLeague(_ sport: Sport) -> [String] {
|
||||
// Get all stadiums for this sport
|
||||
return dataProvider.stadiums
|
||||
.filter { stadium in
|
||||
// Check if stadium hosts teams of this sport
|
||||
dataProvider.teams.contains { team in
|
||||
team.stadiumId == stadium.id && team.sport == sport
|
||||
}
|
||||
}
|
||||
.map { "stadium_\(sport.rawValue.lowercased())_\($0.id.uuidString)" }
|
||||
}
|
||||
|
||||
// MARK: - Data Fetching
|
||||
|
||||
private func fetchAllVisits() throws -> [StadiumVisit] {
|
||||
let descriptor = FetchDescriptor<StadiumVisit>(
|
||||
sortBy: [SortDescriptor(\.visitDate, order: .forward)]
|
||||
)
|
||||
return try modelContext.fetch(descriptor)
|
||||
}
|
||||
|
||||
private func fetchEarnedAchievements() throws -> [Achievement] {
|
||||
let descriptor = FetchDescriptor<Achievement>(
|
||||
predicate: #Predicate { $0.revokedAt == nil }
|
||||
)
|
||||
return try modelContext.fetch(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievement Progress
|
||||
|
||||
struct AchievementProgress: Identifiable {
|
||||
let definition: AchievementDefinition
|
||||
let currentProgress: Int
|
||||
let totalRequired: Int
|
||||
let isEarned: Bool
|
||||
let earnedAt: Date?
|
||||
|
||||
var id: String { definition.id }
|
||||
|
||||
var progressPercentage: Double {
|
||||
guard totalRequired > 0 else { return 0 }
|
||||
return Double(currentProgress) / Double(totalRequired)
|
||||
}
|
||||
|
||||
var progressText: String {
|
||||
if isEarned {
|
||||
return "Completed"
|
||||
}
|
||||
return "\(currentProgress)/\(totalRequired)"
|
||||
}
|
||||
}
|
||||
512
SportsTime/Core/Services/BootstrapService.swift
Normal file
512
SportsTime/Core/Services/BootstrapService.swift
Normal file
@@ -0,0 +1,512 @@
|
||||
//
|
||||
// BootstrapService.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Bootstraps canonical data from bundled JSON files into SwiftData.
|
||||
// Runs once on first launch, then relies on CloudKit for updates.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import CryptoKit
|
||||
|
||||
actor BootstrapService {
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum BootstrapError: Error, LocalizedError {
|
||||
case bundledResourceNotFound(String)
|
||||
case jsonDecodingFailed(String, Error)
|
||||
case saveFailed(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .bundledResourceNotFound(let resource):
|
||||
return "Bundled resource not found: \(resource)"
|
||||
case .jsonDecodingFailed(let resource, let error):
|
||||
return "Failed to decode \(resource): \(error.localizedDescription)"
|
||||
case .saveFailed(let error):
|
||||
return "Failed to save bootstrap data: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - JSON Models (match bundled JSON structure)
|
||||
|
||||
private struct JSONStadium: Codable {
|
||||
let id: String
|
||||
let name: String
|
||||
let city: String
|
||||
let state: String
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let capacity: Int
|
||||
let sport: String
|
||||
let team_abbrevs: [String]
|
||||
let source: String
|
||||
let year_opened: Int?
|
||||
}
|
||||
|
||||
private struct JSONGame: Codable {
|
||||
let id: String
|
||||
let sport: String
|
||||
let season: String
|
||||
let date: String
|
||||
let time: String?
|
||||
let home_team: String
|
||||
let away_team: String
|
||||
let home_team_abbrev: String
|
||||
let away_team_abbrev: String
|
||||
let venue: String
|
||||
let source: String
|
||||
let is_playoff: Bool
|
||||
let broadcast: String?
|
||||
}
|
||||
|
||||
private struct JSONLeagueStructure: Codable {
|
||||
let id: String
|
||||
let sport: String
|
||||
let type: String // "conference", "division", "league"
|
||||
let name: String
|
||||
let abbreviation: String?
|
||||
let parent_id: String?
|
||||
let display_order: Int
|
||||
}
|
||||
|
||||
private struct JSONTeamAlias: Codable {
|
||||
let id: String
|
||||
let team_canonical_id: String
|
||||
let alias_type: String // "abbreviation", "name", "city"
|
||||
let alias_value: String
|
||||
let valid_from: String?
|
||||
let valid_until: String?
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Bootstrap canonical data from bundled JSON if not already done.
|
||||
/// This is the main entry point called at app launch.
|
||||
@MainActor
|
||||
func bootstrapIfNeeded(context: ModelContext) async throws {
|
||||
let syncState = SyncState.current(in: context)
|
||||
|
||||
// Skip if already bootstrapped
|
||||
guard !syncState.bootstrapCompleted else {
|
||||
return
|
||||
}
|
||||
|
||||
// Bootstrap in dependency order
|
||||
try await bootstrapStadiums(context: context)
|
||||
try await bootstrapLeagueStructure(context: context)
|
||||
try await bootstrapTeamsAndGames(context: context)
|
||||
try await bootstrapTeamAliases(context: context)
|
||||
|
||||
// Mark bootstrap complete
|
||||
syncState.bootstrapCompleted = true
|
||||
syncState.bundledSchemaVersion = SchemaVersion.current
|
||||
syncState.lastBootstrap = Date()
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
throw BootstrapError.saveFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap Steps
|
||||
|
||||
@MainActor
|
||||
private func bootstrapStadiums(context: ModelContext) async throws {
|
||||
guard let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") else {
|
||||
throw BootstrapError.bundledResourceNotFound("stadiums.json")
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let stadiums: [JSONStadium]
|
||||
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
stadiums = try JSONDecoder().decode([JSONStadium].self, from: data)
|
||||
} catch {
|
||||
throw BootstrapError.jsonDecodingFailed("stadiums.json", error)
|
||||
}
|
||||
|
||||
// Convert and insert
|
||||
for jsonStadium in stadiums {
|
||||
let canonical = CanonicalStadium(
|
||||
canonicalId: jsonStadium.id,
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: BundledDataTimestamp.stadiums,
|
||||
source: .bundled,
|
||||
name: jsonStadium.name,
|
||||
city: jsonStadium.city,
|
||||
state: jsonStadium.state.isEmpty ? stateFromCity(jsonStadium.city) : jsonStadium.state,
|
||||
latitude: jsonStadium.latitude,
|
||||
longitude: jsonStadium.longitude,
|
||||
capacity: jsonStadium.capacity,
|
||||
yearOpened: jsonStadium.year_opened,
|
||||
sport: jsonStadium.sport
|
||||
)
|
||||
context.insert(canonical)
|
||||
|
||||
// Create stadium alias for the current name (lowercase for matching)
|
||||
let alias = StadiumAlias(
|
||||
aliasName: jsonStadium.name,
|
||||
stadiumCanonicalId: jsonStadium.id,
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: BundledDataTimestamp.stadiums
|
||||
)
|
||||
alias.stadium = canonical
|
||||
context.insert(alias)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func bootstrapLeagueStructure(context: ModelContext) async throws {
|
||||
// Load league structure if file exists
|
||||
guard let url = Bundle.main.url(forResource: "league_structure", withExtension: "json") else {
|
||||
// League structure is optional for MVP - create basic structure from known sports
|
||||
createDefaultLeagueStructure(context: context)
|
||||
return
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let structures: [JSONLeagueStructure]
|
||||
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
structures = try JSONDecoder().decode([JSONLeagueStructure].self, from: data)
|
||||
} catch {
|
||||
throw BootstrapError.jsonDecodingFailed("league_structure.json", error)
|
||||
}
|
||||
|
||||
for structure in structures {
|
||||
let structureType: LeagueStructureType
|
||||
switch structure.type.lowercased() {
|
||||
case "conference": structureType = .conference
|
||||
case "division": structureType = .division
|
||||
case "league": structureType = .league
|
||||
default: structureType = .division
|
||||
}
|
||||
|
||||
let model = LeagueStructureModel(
|
||||
id: structure.id,
|
||||
sport: structure.sport,
|
||||
structureType: structureType,
|
||||
name: structure.name,
|
||||
abbreviation: structure.abbreviation,
|
||||
parentId: structure.parent_id,
|
||||
displayOrder: structure.display_order,
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: BundledDataTimestamp.leagueStructure
|
||||
)
|
||||
context.insert(model)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func bootstrapTeamsAndGames(context: ModelContext) async throws {
|
||||
guard let url = Bundle.main.url(forResource: "games", withExtension: "json") else {
|
||||
throw BootstrapError.bundledResourceNotFound("games.json")
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let games: [JSONGame]
|
||||
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
games = try JSONDecoder().decode([JSONGame].self, from: data)
|
||||
} catch {
|
||||
throw BootstrapError.jsonDecodingFailed("games.json", error)
|
||||
}
|
||||
|
||||
// Build stadium lookup by venue name for game → stadium matching
|
||||
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>()
|
||||
let canonicalStadiums = (try? context.fetch(stadiumDescriptor)) ?? []
|
||||
var stadiumsByVenue: [String: CanonicalStadium] = [:]
|
||||
for stadium in canonicalStadiums {
|
||||
stadiumsByVenue[stadium.name.lowercased()] = stadium
|
||||
}
|
||||
|
||||
// Extract unique teams from games and create CanonicalTeam entries
|
||||
var teamsCreated: [String: CanonicalTeam] = [:]
|
||||
var seenGameIds = Set<String>()
|
||||
|
||||
for jsonGame in games {
|
||||
let sport = jsonGame.sport.uppercased()
|
||||
|
||||
// Process home team
|
||||
let homeTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.home_team_abbrev.lowercased())"
|
||||
if teamsCreated[homeTeamCanonicalId] == nil {
|
||||
let stadiumCanonicalId = findStadiumCanonicalId(
|
||||
venue: jsonGame.venue,
|
||||
sport: sport,
|
||||
stadiumsByVenue: stadiumsByVenue
|
||||
)
|
||||
|
||||
let team = CanonicalTeam(
|
||||
canonicalId: homeTeamCanonicalId,
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: BundledDataTimestamp.games,
|
||||
source: .bundled,
|
||||
name: extractTeamName(from: jsonGame.home_team),
|
||||
abbreviation: jsonGame.home_team_abbrev,
|
||||
sport: sport,
|
||||
city: extractCity(from: jsonGame.home_team),
|
||||
stadiumCanonicalId: stadiumCanonicalId
|
||||
)
|
||||
context.insert(team)
|
||||
teamsCreated[homeTeamCanonicalId] = team
|
||||
}
|
||||
|
||||
// Process away team
|
||||
let awayTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.away_team_abbrev.lowercased())"
|
||||
if teamsCreated[awayTeamCanonicalId] == nil {
|
||||
// Away teams might not have a known stadium yet
|
||||
let team = CanonicalTeam(
|
||||
canonicalId: awayTeamCanonicalId,
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: BundledDataTimestamp.games,
|
||||
source: .bundled,
|
||||
name: extractTeamName(from: jsonGame.away_team),
|
||||
abbreviation: jsonGame.away_team_abbrev,
|
||||
sport: sport,
|
||||
city: extractCity(from: jsonGame.away_team),
|
||||
stadiumCanonicalId: "unknown" // Will be filled in when they're home team
|
||||
)
|
||||
context.insert(team)
|
||||
teamsCreated[awayTeamCanonicalId] = team
|
||||
}
|
||||
|
||||
// Deduplicate games by ID
|
||||
guard !seenGameIds.contains(jsonGame.id) else { continue }
|
||||
seenGameIds.insert(jsonGame.id)
|
||||
|
||||
// Create game
|
||||
guard let dateTime = parseDateTime(date: jsonGame.date, time: jsonGame.time ?? "7:00p") else {
|
||||
continue
|
||||
}
|
||||
|
||||
let stadiumCanonicalId = findStadiumCanonicalId(
|
||||
venue: jsonGame.venue,
|
||||
sport: sport,
|
||||
stadiumsByVenue: stadiumsByVenue
|
||||
)
|
||||
|
||||
let game = CanonicalGame(
|
||||
canonicalId: jsonGame.id,
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: BundledDataTimestamp.games,
|
||||
source: .bundled,
|
||||
homeTeamCanonicalId: homeTeamCanonicalId,
|
||||
awayTeamCanonicalId: awayTeamCanonicalId,
|
||||
stadiumCanonicalId: stadiumCanonicalId,
|
||||
dateTime: dateTime,
|
||||
sport: sport,
|
||||
season: jsonGame.season,
|
||||
isPlayoff: jsonGame.is_playoff,
|
||||
broadcastInfo: jsonGame.broadcast
|
||||
)
|
||||
context.insert(game)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func bootstrapTeamAliases(context: ModelContext) async throws {
|
||||
// Team aliases are optional - load if file exists
|
||||
guard let url = Bundle.main.url(forResource: "team_aliases", withExtension: "json") else {
|
||||
return
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let aliases: [JSONTeamAlias]
|
||||
|
||||
do {
|
||||
data = try Data(contentsOf: url)
|
||||
aliases = try JSONDecoder().decode([JSONTeamAlias].self, from: data)
|
||||
} catch {
|
||||
throw BootstrapError.jsonDecodingFailed("team_aliases.json", error)
|
||||
}
|
||||
|
||||
let dateFormatter = ISO8601DateFormatter()
|
||||
|
||||
for jsonAlias in aliases {
|
||||
let aliasType: TeamAliasType
|
||||
switch jsonAlias.alias_type.lowercased() {
|
||||
case "abbreviation": aliasType = .abbreviation
|
||||
case "name": aliasType = .name
|
||||
case "city": aliasType = .city
|
||||
default: aliasType = .name
|
||||
}
|
||||
|
||||
let alias = TeamAlias(
|
||||
id: jsonAlias.id,
|
||||
teamCanonicalId: jsonAlias.team_canonical_id,
|
||||
aliasType: aliasType,
|
||||
aliasValue: jsonAlias.alias_value,
|
||||
validFrom: jsonAlias.valid_from.flatMap { dateFormatter.date(from: $0) },
|
||||
validUntil: jsonAlias.valid_until.flatMap { dateFormatter.date(from: $0) },
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: BundledDataTimestamp.games
|
||||
)
|
||||
context.insert(alias)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@MainActor
|
||||
private func createDefaultLeagueStructure(context: ModelContext) {
|
||||
// Create minimal league structure for supported sports
|
||||
let timestamp = BundledDataTimestamp.leagueStructure
|
||||
|
||||
// MLB
|
||||
context.insert(LeagueStructureModel(
|
||||
id: "mlb_league",
|
||||
sport: "MLB",
|
||||
structureType: .league,
|
||||
name: "Major League Baseball",
|
||||
abbreviation: "MLB",
|
||||
displayOrder: 0,
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: timestamp
|
||||
))
|
||||
|
||||
// NBA
|
||||
context.insert(LeagueStructureModel(
|
||||
id: "nba_league",
|
||||
sport: "NBA",
|
||||
structureType: .league,
|
||||
name: "National Basketball Association",
|
||||
abbreviation: "NBA",
|
||||
displayOrder: 0,
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: timestamp
|
||||
))
|
||||
|
||||
// NHL
|
||||
context.insert(LeagueStructureModel(
|
||||
id: "nhl_league",
|
||||
sport: "NHL",
|
||||
structureType: .league,
|
||||
name: "National Hockey League",
|
||||
abbreviation: "NHL",
|
||||
displayOrder: 0,
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: timestamp
|
||||
))
|
||||
}
|
||||
|
||||
// Venue name aliases for stadiums that changed names
|
||||
private static let venueAliases: [String: String] = [
|
||||
"daikin park": "minute maid park",
|
||||
"rate field": "guaranteed rate field",
|
||||
"george m. steinbrenner field": "tropicana field",
|
||||
"loandepot park": "loandepot park",
|
||||
]
|
||||
|
||||
nonisolated private func findStadiumCanonicalId(
|
||||
venue: String,
|
||||
sport: String,
|
||||
stadiumsByVenue: [String: CanonicalStadium]
|
||||
) -> String {
|
||||
var venueLower = venue.lowercased()
|
||||
|
||||
// Check for known aliases
|
||||
if let aliasedName = Self.venueAliases[venueLower] {
|
||||
venueLower = aliasedName
|
||||
}
|
||||
|
||||
// Try exact match
|
||||
if let stadium = stadiumsByVenue[venueLower] {
|
||||
return stadium.canonicalId
|
||||
}
|
||||
|
||||
// Try partial match
|
||||
for (name, stadium) in stadiumsByVenue {
|
||||
if name.contains(venueLower) || venueLower.contains(name) {
|
||||
return stadium.canonicalId
|
||||
}
|
||||
}
|
||||
|
||||
// Generate deterministic ID for unknown venues
|
||||
return "venue_unknown_\(venue.lowercased().replacingOccurrences(of: " ", with: "_"))"
|
||||
}
|
||||
|
||||
nonisolated private func parseDateTime(date: String, time: String) -> Date? {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
|
||||
// Parse date
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
guard let dateOnly = formatter.date(from: date) else { return nil }
|
||||
|
||||
// Parse time (e.g., "7:30p", "10:00p", "1:05p")
|
||||
var hour = 12
|
||||
var minute = 0
|
||||
|
||||
let cleanTime = time.lowercased().replacingOccurrences(of: " ", with: "")
|
||||
let isPM = cleanTime.contains("p")
|
||||
let timeWithoutAMPM = cleanTime.replacingOccurrences(of: "p", with: "").replacingOccurrences(of: "a", with: "")
|
||||
|
||||
let components = timeWithoutAMPM.split(separator: ":")
|
||||
if !components.isEmpty, let h = Int(components[0]) {
|
||||
hour = h
|
||||
if isPM && hour != 12 {
|
||||
hour += 12
|
||||
} else if !isPM && hour == 12 {
|
||||
hour = 0
|
||||
}
|
||||
}
|
||||
if components.count > 1, let m = Int(components[1]) {
|
||||
minute = m
|
||||
}
|
||||
|
||||
return Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: dateOnly)
|
||||
}
|
||||
|
||||
nonisolated private func extractTeamName(from fullName: String) -> String {
|
||||
// "Boston Celtics" -> "Celtics"
|
||||
let parts = fullName.split(separator: " ")
|
||||
if parts.count > 1 {
|
||||
return parts.dropFirst().joined(separator: " ")
|
||||
}
|
||||
return fullName
|
||||
}
|
||||
|
||||
nonisolated private func extractCity(from fullName: String) -> String {
|
||||
// "Boston Celtics" -> "Boston"
|
||||
// "New York Knicks" -> "New York"
|
||||
let knownCities = [
|
||||
"New York", "Los Angeles", "San Francisco", "San Diego", "San Antonio",
|
||||
"New Orleans", "Oklahoma City", "Salt Lake City", "Kansas City",
|
||||
"St. Louis", "St Louis"
|
||||
]
|
||||
|
||||
for city in knownCities {
|
||||
if fullName.hasPrefix(city) {
|
||||
return city
|
||||
}
|
||||
}
|
||||
|
||||
// Default: first word
|
||||
return String(fullName.split(separator: " ").first ?? Substring(fullName))
|
||||
}
|
||||
|
||||
nonisolated private func stateFromCity(_ city: String) -> String {
|
||||
let cityToState: [String: String] = [
|
||||
"Atlanta": "GA", "Boston": "MA", "Brooklyn": "NY", "Charlotte": "NC",
|
||||
"Chicago": "IL", "Cleveland": "OH", "Dallas": "TX", "Denver": "CO",
|
||||
"Detroit": "MI", "Houston": "TX", "Indianapolis": "IN", "Los Angeles": "CA",
|
||||
"Memphis": "TN", "Miami": "FL", "Milwaukee": "WI", "Minneapolis": "MN",
|
||||
"New Orleans": "LA", "New York": "NY", "Oklahoma City": "OK", "Orlando": "FL",
|
||||
"Philadelphia": "PA", "Phoenix": "AZ", "Portland": "OR", "Sacramento": "CA",
|
||||
"San Antonio": "TX", "San Francisco": "CA", "Seattle": "WA", "Toronto": "ON",
|
||||
"Washington": "DC", "Las Vegas": "NV", "Tampa": "FL", "Pittsburgh": "PA",
|
||||
"Baltimore": "MD", "Cincinnati": "OH", "St. Louis": "MO", "Kansas City": "MO",
|
||||
"Arlington": "TX", "Anaheim": "CA", "Oakland": "CA", "San Diego": "CA",
|
||||
"Tampa Bay": "FL", "St Petersburg": "FL", "Salt Lake City": "UT"
|
||||
]
|
||||
return cityToState[city] ?? ""
|
||||
}
|
||||
}
|
||||
234
SportsTime/Core/Services/CanonicalDataProvider.swift
Normal file
234
SportsTime/Core/Services/CanonicalDataProvider.swift
Normal file
@@ -0,0 +1,234 @@
|
||||
//
|
||||
// CanonicalDataProvider.swift
|
||||
// SportsTime
|
||||
//
|
||||
// DataProvider implementation that reads from SwiftData canonical models.
|
||||
// This is the primary data source after bootstrap completes.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
actor CanonicalDataProvider: DataProvider {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let modelContainer: ModelContainer
|
||||
|
||||
// Caches for converted domain objects (rebuilt on first access)
|
||||
private var cachedTeams: [Team]?
|
||||
private var cachedStadiums: [Stadium]?
|
||||
private var teamsByCanonicalId: [String: Team] = [:]
|
||||
private var stadiumsByCanonicalId: [String: Stadium] = [:]
|
||||
private var teamUUIDByCanonicalId: [String: UUID] = [:]
|
||||
private var stadiumUUIDByCanonicalId: [String: UUID] = [:]
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(modelContainer: ModelContainer) {
|
||||
self.modelContainer = modelContainer
|
||||
}
|
||||
|
||||
// MARK: - DataProvider Protocol
|
||||
|
||||
func fetchTeams(for sport: Sport) async throws -> [Team] {
|
||||
try await loadCachesIfNeeded()
|
||||
return cachedTeams?.filter { $0.sport == sport } ?? []
|
||||
}
|
||||
|
||||
func fetchAllTeams() async throws -> [Team] {
|
||||
try await loadCachesIfNeeded()
|
||||
return cachedTeams ?? []
|
||||
}
|
||||
|
||||
func fetchStadiums() async throws -> [Stadium] {
|
||||
try await loadCachesIfNeeded()
|
||||
return cachedStadiums ?? []
|
||||
}
|
||||
|
||||
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
|
||||
try await loadCachesIfNeeded()
|
||||
|
||||
let context = ModelContext(modelContainer)
|
||||
|
||||
// Fetch canonical games within date range
|
||||
let sportStrings = sports.map { $0.rawValue }
|
||||
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||
predicate: #Predicate<CanonicalGame> { game in
|
||||
sportStrings.contains(game.sport) &&
|
||||
game.dateTime >= startDate &&
|
||||
game.dateTime <= endDate &&
|
||||
game.deprecatedAt == nil
|
||||
},
|
||||
sortBy: [SortDescriptor(\.dateTime)]
|
||||
)
|
||||
|
||||
let canonicalGames = try context.fetch(descriptor)
|
||||
|
||||
// Convert to domain models
|
||||
return canonicalGames.compactMap { canonical -> Game? in
|
||||
guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId],
|
||||
let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId],
|
||||
let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Game(
|
||||
id: canonical.uuid,
|
||||
homeTeamId: homeTeamUUID,
|
||||
awayTeamId: awayTeamUUID,
|
||||
stadiumId: stadiumUUID,
|
||||
dateTime: canonical.dateTime,
|
||||
sport: canonical.sportEnum ?? .mlb,
|
||||
season: canonical.season,
|
||||
isPlayoff: canonical.isPlayoff,
|
||||
broadcastInfo: canonical.broadcastInfo
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchGame(by id: UUID) async throws -> Game? {
|
||||
try await loadCachesIfNeeded()
|
||||
|
||||
let context = ModelContext(modelContainer)
|
||||
|
||||
// Search by UUID
|
||||
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||
predicate: #Predicate<CanonicalGame> { game in
|
||||
game.uuid == id && game.deprecatedAt == nil
|
||||
}
|
||||
)
|
||||
|
||||
guard let canonical = try context.fetch(descriptor).first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId],
|
||||
let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId],
|
||||
let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Game(
|
||||
id: canonical.uuid,
|
||||
homeTeamId: homeTeamUUID,
|
||||
awayTeamId: awayTeamUUID,
|
||||
stadiumId: stadiumUUID,
|
||||
dateTime: canonical.dateTime,
|
||||
sport: canonical.sportEnum ?? .mlb,
|
||||
season: canonical.season,
|
||||
isPlayoff: canonical.isPlayoff,
|
||||
broadcastInfo: canonical.broadcastInfo
|
||||
)
|
||||
}
|
||||
|
||||
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
|
||||
try await loadCachesIfNeeded()
|
||||
|
||||
let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate)
|
||||
let teamsById = Dictionary(uniqueKeysWithValues: (cachedTeams ?? []).map { ($0.id, $0) })
|
||||
let stadiumsById = Dictionary(uniqueKeysWithValues: (cachedStadiums ?? []).map { ($0.id, $0) })
|
||||
|
||||
return games.compactMap { game in
|
||||
guard let homeTeam = teamsById[game.homeTeamId],
|
||||
let awayTeam = teamsById[game.awayTeamId],
|
||||
let stadium = stadiumsById[game.stadiumId] else {
|
||||
return nil
|
||||
}
|
||||
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Additional Queries
|
||||
|
||||
/// Fetch stadium by canonical ID (useful for visit tracking)
|
||||
func fetchStadium(byCanonicalId canonicalId: String) async throws -> Stadium? {
|
||||
try await loadCachesIfNeeded()
|
||||
return stadiumsByCanonicalId[canonicalId]
|
||||
}
|
||||
|
||||
/// Fetch team by canonical ID
|
||||
func fetchTeam(byCanonicalId canonicalId: String) async throws -> Team? {
|
||||
try await loadCachesIfNeeded()
|
||||
return teamsByCanonicalId[canonicalId]
|
||||
}
|
||||
|
||||
/// Find stadium by name (matches aliases)
|
||||
func findStadium(byName name: String) async throws -> Stadium? {
|
||||
let context = ModelContext(modelContainer)
|
||||
|
||||
// Precompute lowercased name outside the predicate
|
||||
let lowercasedName = name.lowercased()
|
||||
|
||||
// First try exact alias match
|
||||
let aliasDescriptor = FetchDescriptor<StadiumAlias>(
|
||||
predicate: #Predicate<StadiumAlias> { alias in
|
||||
alias.aliasName == lowercasedName
|
||||
}
|
||||
)
|
||||
|
||||
if let alias = try context.fetch(aliasDescriptor).first,
|
||||
let stadiumCanonicalId = Optional(alias.stadiumCanonicalId) {
|
||||
return try await fetchStadium(byCanonicalId: stadiumCanonicalId)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Invalidate caches (call after sync completes)
|
||||
func invalidateCaches() {
|
||||
cachedTeams = nil
|
||||
cachedStadiums = nil
|
||||
teamsByCanonicalId.removeAll()
|
||||
stadiumsByCanonicalId.removeAll()
|
||||
teamUUIDByCanonicalId.removeAll()
|
||||
stadiumUUIDByCanonicalId.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func loadCachesIfNeeded() async throws {
|
||||
guard cachedTeams == nil else { return }
|
||||
|
||||
let context = ModelContext(modelContainer)
|
||||
|
||||
// Load stadiums
|
||||
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate<CanonicalStadium> { stadium in
|
||||
stadium.deprecatedAt == nil
|
||||
}
|
||||
)
|
||||
let canonicalStadiums = try context.fetch(stadiumDescriptor)
|
||||
|
||||
cachedStadiums = canonicalStadiums.map { canonical in
|
||||
let stadium = canonical.toDomain()
|
||||
stadiumsByCanonicalId[canonical.canonicalId] = stadium
|
||||
stadiumUUIDByCanonicalId[canonical.canonicalId] = stadium.id
|
||||
return stadium
|
||||
}
|
||||
|
||||
// Load teams
|
||||
let teamDescriptor = FetchDescriptor<CanonicalTeam>(
|
||||
predicate: #Predicate<CanonicalTeam> { team in
|
||||
team.deprecatedAt == nil
|
||||
}
|
||||
)
|
||||
let canonicalTeams = try context.fetch(teamDescriptor)
|
||||
|
||||
cachedTeams = canonicalTeams.compactMap { canonical -> Team? in
|
||||
guard let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
|
||||
// Generate a placeholder UUID for teams without known stadiums
|
||||
let placeholderUUID = CanonicalStadium.deterministicUUID(from: canonical.stadiumCanonicalId)
|
||||
let team = canonical.toDomain(stadiumUUID: placeholderUUID)
|
||||
teamsByCanonicalId[canonical.canonicalId] = team
|
||||
teamUUIDByCanonicalId[canonical.canonicalId] = team.id
|
||||
return team
|
||||
}
|
||||
|
||||
let team = canonical.toDomain(stadiumUUID: stadiumUUID)
|
||||
teamsByCanonicalId[canonical.canonicalId] = team
|
||||
teamUUIDByCanonicalId[canonical.canonicalId] = team.id
|
||||
return team
|
||||
}
|
||||
}
|
||||
}
|
||||
634
SportsTime/Core/Services/CanonicalSyncService.swift
Normal file
634
SportsTime/Core/Services/CanonicalSyncService.swift
Normal file
@@ -0,0 +1,634 @@
|
||||
//
|
||||
// CanonicalSyncService.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Orchestrates syncing canonical data from CloudKit into SwiftData.
|
||||
// Uses date-based delta sync for public database efficiency.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import CloudKit
|
||||
|
||||
actor CanonicalSyncService {
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum SyncError: Error, LocalizedError {
|
||||
case cloudKitUnavailable
|
||||
case syncAlreadyInProgress
|
||||
case saveFailed(Error)
|
||||
case schemaVersionTooNew(Int)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .cloudKitUnavailable:
|
||||
return "CloudKit is not available. Check your internet connection and iCloud settings."
|
||||
case .syncAlreadyInProgress:
|
||||
return "A sync operation is already in progress."
|
||||
case .saveFailed(let error):
|
||||
return "Failed to save synced data: \(error.localizedDescription)"
|
||||
case .schemaVersionTooNew(let version):
|
||||
return "Data requires app version supporting schema \(version). Please update the app."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sync Result
|
||||
|
||||
struct SyncResult {
|
||||
let stadiumsUpdated: Int
|
||||
let teamsUpdated: Int
|
||||
let gamesUpdated: Int
|
||||
let leagueStructuresUpdated: Int
|
||||
let teamAliasesUpdated: Int
|
||||
let skippedIncompatible: Int
|
||||
let skippedOlder: Int
|
||||
let duration: TimeInterval
|
||||
|
||||
var totalUpdated: Int {
|
||||
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated
|
||||
}
|
||||
|
||||
var isEmpty: Bool { totalUpdated == 0 }
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let cloudKitService: CloudKitService
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(cloudKitService: CloudKitService = .shared) {
|
||||
self.cloudKitService = cloudKitService
|
||||
}
|
||||
|
||||
// MARK: - Public Sync Methods
|
||||
|
||||
/// Perform a full sync of all canonical data types.
|
||||
/// This is the main entry point for background sync.
|
||||
@MainActor
|
||||
func syncAll(context: ModelContext) async throws -> SyncResult {
|
||||
let startTime = Date()
|
||||
let syncState = SyncState.current(in: context)
|
||||
|
||||
// Prevent concurrent syncs
|
||||
guard !syncState.syncInProgress else {
|
||||
throw SyncError.syncAlreadyInProgress
|
||||
}
|
||||
|
||||
// Check if sync is enabled
|
||||
guard syncState.syncEnabled else {
|
||||
return SyncResult(
|
||||
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
|
||||
leagueStructuresUpdated: 0, teamAliasesUpdated: 0,
|
||||
skippedIncompatible: 0, skippedOlder: 0,
|
||||
duration: 0
|
||||
)
|
||||
}
|
||||
|
||||
// Check CloudKit availability
|
||||
guard await cloudKitService.isAvailable() else {
|
||||
throw SyncError.cloudKitUnavailable
|
||||
}
|
||||
|
||||
// Mark sync in progress
|
||||
syncState.syncInProgress = true
|
||||
syncState.lastSyncAttempt = Date()
|
||||
|
||||
var totalStadiums = 0
|
||||
var totalTeams = 0
|
||||
var totalGames = 0
|
||||
var totalLeagueStructures = 0
|
||||
var totalTeamAliases = 0
|
||||
var totalSkippedIncompatible = 0
|
||||
var totalSkippedOlder = 0
|
||||
|
||||
do {
|
||||
// Sync in dependency order
|
||||
let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums(
|
||||
context: context,
|
||||
since: syncState.lastSuccessfulSync
|
||||
)
|
||||
totalStadiums = stadiums
|
||||
totalSkippedIncompatible += skipIncompat1
|
||||
totalSkippedOlder += skipOlder1
|
||||
|
||||
let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure(
|
||||
context: context,
|
||||
since: syncState.lastSuccessfulSync
|
||||
)
|
||||
totalLeagueStructures = leagueStructures
|
||||
totalSkippedIncompatible += skipIncompat2
|
||||
totalSkippedOlder += skipOlder2
|
||||
|
||||
let (teams, skipIncompat3, skipOlder3) = try await syncTeams(
|
||||
context: context,
|
||||
since: syncState.lastSuccessfulSync
|
||||
)
|
||||
totalTeams = teams
|
||||
totalSkippedIncompatible += skipIncompat3
|
||||
totalSkippedOlder += skipOlder3
|
||||
|
||||
let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases(
|
||||
context: context,
|
||||
since: syncState.lastSuccessfulSync
|
||||
)
|
||||
totalTeamAliases = teamAliases
|
||||
totalSkippedIncompatible += skipIncompat4
|
||||
totalSkippedOlder += skipOlder4
|
||||
|
||||
let (games, skipIncompat5, skipOlder5) = try await syncGames(
|
||||
context: context,
|
||||
since: syncState.lastSuccessfulSync
|
||||
)
|
||||
totalGames = games
|
||||
totalSkippedIncompatible += skipIncompat5
|
||||
totalSkippedOlder += skipOlder5
|
||||
|
||||
// Mark sync successful
|
||||
syncState.syncInProgress = false
|
||||
syncState.lastSuccessfulSync = Date()
|
||||
syncState.lastSyncError = nil
|
||||
syncState.consecutiveFailures = 0
|
||||
|
||||
try context.save()
|
||||
|
||||
} catch {
|
||||
// Mark sync failed
|
||||
syncState.syncInProgress = false
|
||||
syncState.lastSyncError = error.localizedDescription
|
||||
syncState.consecutiveFailures += 1
|
||||
|
||||
// Pause sync after too many failures
|
||||
if syncState.consecutiveFailures >= 5 {
|
||||
syncState.syncEnabled = false
|
||||
syncState.syncPausedReason = "Too many consecutive failures. Sync paused."
|
||||
}
|
||||
|
||||
try? context.save()
|
||||
throw error
|
||||
}
|
||||
|
||||
return SyncResult(
|
||||
stadiumsUpdated: totalStadiums,
|
||||
teamsUpdated: totalTeams,
|
||||
gamesUpdated: totalGames,
|
||||
leagueStructuresUpdated: totalLeagueStructures,
|
||||
teamAliasesUpdated: totalTeamAliases,
|
||||
skippedIncompatible: totalSkippedIncompatible,
|
||||
skippedOlder: totalSkippedOlder,
|
||||
duration: Date().timeIntervalSince(startTime)
|
||||
)
|
||||
}
|
||||
|
||||
/// Re-enable sync after it was paused due to failures.
|
||||
@MainActor
|
||||
func resumeSync(context: ModelContext) {
|
||||
let syncState = SyncState.current(in: context)
|
||||
syncState.syncEnabled = true
|
||||
syncState.syncPausedReason = nil
|
||||
syncState.consecutiveFailures = 0
|
||||
try? context.save()
|
||||
}
|
||||
|
||||
// MARK: - Individual Sync Methods
|
||||
|
||||
@MainActor
|
||||
private func syncStadiums(
|
||||
context: ModelContext,
|
||||
since lastSync: Date?
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
let remoteStadiums = try await cloudKitService.fetchStadiums()
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
var skippedOlder = 0
|
||||
|
||||
for remoteStadium in remoteStadiums {
|
||||
// For now, fetch full list and merge - CloudKit public DB doesn't have delta sync
|
||||
// In future, could add lastModified filtering on CloudKit query
|
||||
|
||||
let canonicalId = "stadium_\(remoteStadium.sport.rawValue.lowercased())_\(remoteStadium.id.uuidString.prefix(8))"
|
||||
|
||||
let result = try mergeStadium(
|
||||
remoteStadium,
|
||||
canonicalId: canonicalId,
|
||||
context: context
|
||||
)
|
||||
|
||||
switch result {
|
||||
case .applied: updated += 1
|
||||
case .skippedIncompatible: skippedIncompatible += 1
|
||||
case .skippedOlder: skippedOlder += 1
|
||||
}
|
||||
}
|
||||
|
||||
return (updated, skippedIncompatible, skippedOlder)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func syncTeams(
|
||||
context: ModelContext,
|
||||
since lastSync: Date?
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
// Fetch teams for all sports
|
||||
var allTeams: [Team] = []
|
||||
for sport in Sport.allCases {
|
||||
let teams = try await cloudKitService.fetchTeams(for: sport)
|
||||
allTeams.append(contentsOf: teams)
|
||||
}
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
var skippedOlder = 0
|
||||
|
||||
for remoteTeam in allTeams {
|
||||
let canonicalId = "team_\(remoteTeam.sport.rawValue.lowercased())_\(remoteTeam.abbreviation.lowercased())"
|
||||
|
||||
let result = try mergeTeam(
|
||||
remoteTeam,
|
||||
canonicalId: canonicalId,
|
||||
context: context
|
||||
)
|
||||
|
||||
switch result {
|
||||
case .applied: updated += 1
|
||||
case .skippedIncompatible: skippedIncompatible += 1
|
||||
case .skippedOlder: skippedOlder += 1
|
||||
}
|
||||
}
|
||||
|
||||
return (updated, skippedIncompatible, skippedOlder)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func syncGames(
|
||||
context: ModelContext,
|
||||
since lastSync: Date?
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
// Fetch games for the next 6 months from all sports
|
||||
let startDate = lastSync ?? Date()
|
||||
let endDate = Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date()
|
||||
|
||||
let remoteGames = try await cloudKitService.fetchGames(
|
||||
sports: Set(Sport.allCases),
|
||||
startDate: startDate,
|
||||
endDate: endDate
|
||||
)
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
var skippedOlder = 0
|
||||
|
||||
for remoteGame in remoteGames {
|
||||
let result = try mergeGame(
|
||||
remoteGame,
|
||||
canonicalId: remoteGame.id.uuidString,
|
||||
context: context
|
||||
)
|
||||
|
||||
switch result {
|
||||
case .applied: updated += 1
|
||||
case .skippedIncompatible: skippedIncompatible += 1
|
||||
case .skippedOlder: skippedOlder += 1
|
||||
}
|
||||
}
|
||||
|
||||
return (updated, skippedIncompatible, skippedOlder)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func syncLeagueStructure(
|
||||
context: ModelContext,
|
||||
since lastSync: Date?
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync)
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
var skippedOlder = 0
|
||||
|
||||
for remoteStructure in remoteStructures {
|
||||
let result = try mergeLeagueStructure(remoteStructure, context: context)
|
||||
|
||||
switch result {
|
||||
case .applied: updated += 1
|
||||
case .skippedIncompatible: skippedIncompatible += 1
|
||||
case .skippedOlder: skippedOlder += 1
|
||||
}
|
||||
}
|
||||
|
||||
return (updated, skippedIncompatible, skippedOlder)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func syncTeamAliases(
|
||||
context: ModelContext,
|
||||
since lastSync: Date?
|
||||
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
|
||||
let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync)
|
||||
|
||||
var updated = 0
|
||||
var skippedIncompatible = 0
|
||||
var skippedOlder = 0
|
||||
|
||||
for remoteAlias in remoteAliases {
|
||||
let result = try mergeTeamAlias(remoteAlias, context: context)
|
||||
|
||||
switch result {
|
||||
case .applied: updated += 1
|
||||
case .skippedIncompatible: skippedIncompatible += 1
|
||||
case .skippedOlder: skippedOlder += 1
|
||||
}
|
||||
}
|
||||
|
||||
return (updated, skippedIncompatible, skippedOlder)
|
||||
}
|
||||
|
||||
// MARK: - Merge Logic
|
||||
|
||||
private enum MergeResult {
|
||||
case applied
|
||||
case skippedIncompatible
|
||||
case skippedOlder
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func mergeStadium(
|
||||
_ remote: Stadium,
|
||||
canonicalId: String,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
// Look up existing
|
||||
let descriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate { $0.canonicalId == canonicalId }
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
|
||||
if let existing = existing {
|
||||
// Preserve user fields
|
||||
let savedNickname = existing.userNickname
|
||||
let savedNotes = existing.userNotes
|
||||
let savedFavorite = existing.isFavorite
|
||||
|
||||
// Update system fields
|
||||
existing.name = remote.name
|
||||
existing.city = remote.city
|
||||
existing.state = remote.state
|
||||
existing.latitude = remote.latitude
|
||||
existing.longitude = remote.longitude
|
||||
existing.capacity = remote.capacity
|
||||
existing.yearOpened = remote.yearOpened
|
||||
existing.imageURL = remote.imageURL?.absoluteString
|
||||
existing.sport = remote.sport.rawValue
|
||||
existing.source = .cloudKit
|
||||
existing.lastModified = Date()
|
||||
|
||||
// Restore user fields
|
||||
existing.userNickname = savedNickname
|
||||
existing.userNotes = savedNotes
|
||||
existing.isFavorite = savedFavorite
|
||||
|
||||
return .applied
|
||||
} else {
|
||||
// Insert new
|
||||
let canonical = CanonicalStadium(
|
||||
canonicalId: canonicalId,
|
||||
uuid: remote.id,
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: Date(),
|
||||
source: .cloudKit,
|
||||
name: remote.name,
|
||||
city: remote.city,
|
||||
state: remote.state,
|
||||
latitude: remote.latitude,
|
||||
longitude: remote.longitude,
|
||||
capacity: remote.capacity,
|
||||
yearOpened: remote.yearOpened,
|
||||
imageURL: remote.imageURL?.absoluteString,
|
||||
sport: remote.sport.rawValue
|
||||
)
|
||||
context.insert(canonical)
|
||||
return .applied
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func mergeTeam(
|
||||
_ remote: Team,
|
||||
canonicalId: String,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
let descriptor = FetchDescriptor<CanonicalTeam>(
|
||||
predicate: #Predicate { $0.canonicalId == canonicalId }
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
|
||||
// Find stadium canonical ID
|
||||
let remoteStadiumId = remote.stadiumId
|
||||
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate { $0.uuid == remoteStadiumId }
|
||||
)
|
||||
let stadium = try context.fetch(stadiumDescriptor).first
|
||||
let stadiumCanonicalId = stadium?.canonicalId ?? "unknown"
|
||||
|
||||
if let existing = existing {
|
||||
// Preserve user fields
|
||||
let savedNickname = existing.userNickname
|
||||
let savedFavorite = existing.isFavorite
|
||||
|
||||
// Update system fields
|
||||
existing.name = remote.name
|
||||
existing.abbreviation = remote.abbreviation
|
||||
existing.sport = remote.sport.rawValue
|
||||
existing.city = remote.city
|
||||
existing.stadiumCanonicalId = stadiumCanonicalId
|
||||
existing.logoURL = remote.logoURL?.absoluteString
|
||||
existing.primaryColor = remote.primaryColor
|
||||
existing.secondaryColor = remote.secondaryColor
|
||||
existing.source = .cloudKit
|
||||
existing.lastModified = Date()
|
||||
|
||||
// Restore user fields
|
||||
existing.userNickname = savedNickname
|
||||
existing.isFavorite = savedFavorite
|
||||
|
||||
return .applied
|
||||
} else {
|
||||
let canonical = CanonicalTeam(
|
||||
canonicalId: canonicalId,
|
||||
uuid: remote.id,
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: Date(),
|
||||
source: .cloudKit,
|
||||
name: remote.name,
|
||||
abbreviation: remote.abbreviation,
|
||||
sport: remote.sport.rawValue,
|
||||
city: remote.city,
|
||||
stadiumCanonicalId: stadiumCanonicalId,
|
||||
logoURL: remote.logoURL?.absoluteString,
|
||||
primaryColor: remote.primaryColor,
|
||||
secondaryColor: remote.secondaryColor
|
||||
)
|
||||
context.insert(canonical)
|
||||
return .applied
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func mergeGame(
|
||||
_ remote: Game,
|
||||
canonicalId: String,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
let descriptor = FetchDescriptor<CanonicalGame>(
|
||||
predicate: #Predicate { $0.canonicalId == canonicalId }
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
|
||||
// Look up canonical IDs for teams and stadium
|
||||
let remoteHomeTeamId = remote.homeTeamId
|
||||
let remoteAwayTeamId = remote.awayTeamId
|
||||
let remoteStadiumId = remote.stadiumId
|
||||
|
||||
let homeTeamDescriptor = FetchDescriptor<CanonicalTeam>(
|
||||
predicate: #Predicate { $0.uuid == remoteHomeTeamId }
|
||||
)
|
||||
let awayTeamDescriptor = FetchDescriptor<CanonicalTeam>(
|
||||
predicate: #Predicate { $0.uuid == remoteAwayTeamId }
|
||||
)
|
||||
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate { $0.uuid == remoteStadiumId }
|
||||
)
|
||||
|
||||
let homeTeam = try context.fetch(homeTeamDescriptor).first
|
||||
let awayTeam = try context.fetch(awayTeamDescriptor).first
|
||||
let stadium = try context.fetch(stadiumDescriptor).first
|
||||
|
||||
let homeTeamCanonicalId = homeTeam?.canonicalId ?? "unknown"
|
||||
let awayTeamCanonicalId = awayTeam?.canonicalId ?? "unknown"
|
||||
let stadiumCanonicalId = stadium?.canonicalId ?? "unknown"
|
||||
|
||||
if let existing = existing {
|
||||
// Preserve user fields
|
||||
let savedAttending = existing.userAttending
|
||||
let savedNotes = existing.userNotes
|
||||
|
||||
// Update system fields
|
||||
existing.homeTeamCanonicalId = homeTeamCanonicalId
|
||||
existing.awayTeamCanonicalId = awayTeamCanonicalId
|
||||
existing.stadiumCanonicalId = stadiumCanonicalId
|
||||
existing.dateTime = remote.dateTime
|
||||
existing.sport = remote.sport.rawValue
|
||||
existing.season = remote.season
|
||||
existing.isPlayoff = remote.isPlayoff
|
||||
existing.broadcastInfo = remote.broadcastInfo
|
||||
existing.source = .cloudKit
|
||||
existing.lastModified = Date()
|
||||
|
||||
// Restore user fields
|
||||
existing.userAttending = savedAttending
|
||||
existing.userNotes = savedNotes
|
||||
|
||||
return .applied
|
||||
} else {
|
||||
let canonical = CanonicalGame(
|
||||
canonicalId: canonicalId,
|
||||
uuid: remote.id,
|
||||
schemaVersion: SchemaVersion.current,
|
||||
lastModified: Date(),
|
||||
source: .cloudKit,
|
||||
homeTeamCanonicalId: homeTeamCanonicalId,
|
||||
awayTeamCanonicalId: awayTeamCanonicalId,
|
||||
stadiumCanonicalId: stadiumCanonicalId,
|
||||
dateTime: remote.dateTime,
|
||||
sport: remote.sport.rawValue,
|
||||
season: remote.season,
|
||||
isPlayoff: remote.isPlayoff,
|
||||
broadcastInfo: remote.broadcastInfo
|
||||
)
|
||||
context.insert(canonical)
|
||||
return .applied
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func mergeLeagueStructure(
|
||||
_ remote: LeagueStructureModel,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
// Schema version check
|
||||
guard remote.schemaVersion <= SchemaVersion.current else {
|
||||
return .skippedIncompatible
|
||||
}
|
||||
|
||||
let remoteId = remote.id
|
||||
let descriptor = FetchDescriptor<LeagueStructureModel>(
|
||||
predicate: #Predicate { $0.id == remoteId }
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
|
||||
if let existing = existing {
|
||||
// lastModified check
|
||||
guard remote.lastModified > existing.lastModified else {
|
||||
return .skippedOlder
|
||||
}
|
||||
|
||||
// Update all fields (no user fields on LeagueStructure)
|
||||
existing.sport = remote.sport
|
||||
existing.structureTypeRaw = remote.structureTypeRaw
|
||||
existing.name = remote.name
|
||||
existing.abbreviation = remote.abbreviation
|
||||
existing.parentId = remote.parentId
|
||||
existing.displayOrder = remote.displayOrder
|
||||
existing.schemaVersion = remote.schemaVersion
|
||||
existing.lastModified = remote.lastModified
|
||||
|
||||
return .applied
|
||||
} else {
|
||||
// Insert new
|
||||
context.insert(remote)
|
||||
return .applied
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func mergeTeamAlias(
|
||||
_ remote: TeamAlias,
|
||||
context: ModelContext
|
||||
) throws -> MergeResult {
|
||||
// Schema version check
|
||||
guard remote.schemaVersion <= SchemaVersion.current else {
|
||||
return .skippedIncompatible
|
||||
}
|
||||
|
||||
let remoteId = remote.id
|
||||
let descriptor = FetchDescriptor<TeamAlias>(
|
||||
predicate: #Predicate { $0.id == remoteId }
|
||||
)
|
||||
let existing = try context.fetch(descriptor).first
|
||||
|
||||
if let existing = existing {
|
||||
// lastModified check
|
||||
guard remote.lastModified > existing.lastModified else {
|
||||
return .skippedOlder
|
||||
}
|
||||
|
||||
// Update all fields (no user fields on TeamAlias)
|
||||
existing.teamCanonicalId = remote.teamCanonicalId
|
||||
existing.aliasTypeRaw = remote.aliasTypeRaw
|
||||
existing.aliasValue = remote.aliasValue
|
||||
existing.validFrom = remote.validFrom
|
||||
existing.validUntil = remote.validUntil
|
||||
existing.schemaVersion = remote.schemaVersion
|
||||
existing.lastModified = remote.lastModified
|
||||
|
||||
return .applied
|
||||
} else {
|
||||
// Insert new
|
||||
context.insert(remote)
|
||||
return .applied
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,87 @@ actor CloudKitService {
|
||||
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
|
||||
}
|
||||
|
||||
// MARK: - League Structure & Team Aliases
|
||||
|
||||
func fetchLeagueStructure(for sport: Sport? = nil) async throws -> [LeagueStructureModel] {
|
||||
let predicate: NSPredicate
|
||||
if let sport = sport {
|
||||
predicate = NSPredicate(format: "sport == %@", sport.rawValue)
|
||||
} else {
|
||||
predicate = NSPredicate(value: true)
|
||||
}
|
||||
|
||||
let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate)
|
||||
query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.displayOrderKey, ascending: true)]
|
||||
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
|
||||
return results.compactMap { result in
|
||||
guard case .success(let record) = result.1 else { return nil }
|
||||
return CKLeagueStructure(record: record).toModel()
|
||||
}
|
||||
}
|
||||
|
||||
func fetchTeamAliases(for teamCanonicalId: String? = nil) async throws -> [TeamAlias] {
|
||||
let predicate: NSPredicate
|
||||
if let teamId = teamCanonicalId {
|
||||
predicate = NSPredicate(format: "teamCanonicalId == %@", teamId)
|
||||
} else {
|
||||
predicate = NSPredicate(value: true)
|
||||
}
|
||||
|
||||
let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate)
|
||||
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
|
||||
return results.compactMap { result in
|
||||
guard case .success(let record) = result.1 else { return nil }
|
||||
return CKTeamAlias(record: record).toModel()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delta Sync (Date-Based for Public Database)
|
||||
|
||||
/// Fetch league structure records modified after the given date
|
||||
func fetchLeagueStructureChanges(since lastSync: Date?) async throws -> [LeagueStructureModel] {
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
||||
} else {
|
||||
predicate = NSPredicate(value: true)
|
||||
}
|
||||
|
||||
let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate)
|
||||
query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.lastModifiedKey, ascending: true)]
|
||||
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
|
||||
return results.compactMap { result in
|
||||
guard case .success(let record) = result.1 else { return nil }
|
||||
return CKLeagueStructure(record: record).toModel()
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch team alias records modified after the given date
|
||||
func fetchTeamAliasChanges(since lastSync: Date?) async throws -> [TeamAlias] {
|
||||
let predicate: NSPredicate
|
||||
if let lastSync = lastSync {
|
||||
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
||||
} else {
|
||||
predicate = NSPredicate(value: true)
|
||||
}
|
||||
|
||||
let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate)
|
||||
query.sortDescriptors = [NSSortDescriptor(key: CKTeamAlias.lastModifiedKey, ascending: true)]
|
||||
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
|
||||
return results.compactMap { result in
|
||||
guard case .success(let record) = result.1 else { return nil }
|
||||
return CKTeamAlias(record: record).toModel()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sync Status
|
||||
|
||||
func checkAccountStatus() async -> CKAccountStatus {
|
||||
@@ -199,7 +280,7 @@ actor CloudKitService {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription (for schedule updates)
|
||||
// MARK: - Subscriptions
|
||||
|
||||
func subscribeToScheduleUpdates() async throws {
|
||||
let subscription = CKQuerySubscription(
|
||||
@@ -215,4 +296,41 @@ actor CloudKitService {
|
||||
|
||||
try await publicDatabase.save(subscription)
|
||||
}
|
||||
|
||||
func subscribeToLeagueStructureUpdates() async throws {
|
||||
let subscription = CKQuerySubscription(
|
||||
recordType: CKRecordType.leagueStructure,
|
||||
predicate: NSPredicate(value: true),
|
||||
subscriptionID: "league-structure-updates",
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
||||
)
|
||||
|
||||
let notification = CKSubscription.NotificationInfo()
|
||||
notification.shouldSendContentAvailable = true
|
||||
subscription.notificationInfo = notification
|
||||
|
||||
try await publicDatabase.save(subscription)
|
||||
}
|
||||
|
||||
func subscribeToTeamAliasUpdates() async throws {
|
||||
let subscription = CKQuerySubscription(
|
||||
recordType: CKRecordType.teamAlias,
|
||||
predicate: NSPredicate(value: true),
|
||||
subscriptionID: "team-alias-updates",
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
||||
)
|
||||
|
||||
let notification = CKSubscription.NotificationInfo()
|
||||
notification.shouldSendContentAvailable = true
|
||||
subscription.notificationInfo = notification
|
||||
|
||||
try await publicDatabase.save(subscription)
|
||||
}
|
||||
|
||||
/// Subscribe to all canonical data updates
|
||||
func subscribeToAllUpdates() async throws {
|
||||
try await subscribeToScheduleUpdates()
|
||||
try await subscribeToLeagueStructureUpdates()
|
||||
try await subscribeToTeamAliasUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
298
SportsTime/Core/Services/FreeScoreAPI.swift
Normal file
298
SportsTime/Core/Services/FreeScoreAPI.swift
Normal file
@@ -0,0 +1,298 @@
|
||||
//
|
||||
// FreeScoreAPI.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Multi-provider score resolution facade using FREE data sources only.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Provider Reliability
|
||||
|
||||
enum ProviderReliability: String, Sendable {
|
||||
case official // MLB Stats, NHL Stats - stable, documented
|
||||
case unofficial // ESPN API - works but may break
|
||||
case scraped // Sports-Reference - HTML parsing, fragile
|
||||
}
|
||||
|
||||
// MARK: - Historical Game Query
|
||||
|
||||
struct HistoricalGameQuery: Sendable {
|
||||
let sport: Sport
|
||||
let date: Date
|
||||
let homeTeamAbbrev: String?
|
||||
let awayTeamAbbrev: String?
|
||||
let stadiumCanonicalId: String?
|
||||
|
||||
init(
|
||||
sport: Sport,
|
||||
date: Date,
|
||||
homeTeamAbbrev: String? = nil,
|
||||
awayTeamAbbrev: String? = nil,
|
||||
stadiumCanonicalId: String? = nil
|
||||
) {
|
||||
self.sport = sport
|
||||
self.date = date
|
||||
self.homeTeamAbbrev = homeTeamAbbrev
|
||||
self.awayTeamAbbrev = awayTeamAbbrev
|
||||
self.stadiumCanonicalId = stadiumCanonicalId
|
||||
}
|
||||
|
||||
/// Normalized date string for matching
|
||||
var normalizedDateString: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.timeZone = TimeZone(identifier: "America/New_York")
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Historical Game Result
|
||||
|
||||
struct HistoricalGameResult: Sendable {
|
||||
let sport: Sport
|
||||
let gameDate: Date
|
||||
let homeTeamAbbrev: String
|
||||
let awayTeamAbbrev: String
|
||||
let homeTeamName: String
|
||||
let awayTeamName: String
|
||||
let homeScore: Int?
|
||||
let awayScore: Int?
|
||||
let source: ScoreSource
|
||||
let providerName: String
|
||||
|
||||
var scoreString: String? {
|
||||
guard let home = homeScore, let away = awayScore else { return nil }
|
||||
return "\(away)-\(home)"
|
||||
}
|
||||
|
||||
var hasScore: Bool {
|
||||
homeScore != nil && awayScore != nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Score Resolution Result
|
||||
|
||||
enum ScoreResolutionResult: Sendable {
|
||||
case resolved(HistoricalGameResult)
|
||||
case pending // Background retry queued
|
||||
case requiresUserInput(reason: String) // All tiers failed
|
||||
case notFound(reason: String) // No game matched query
|
||||
|
||||
var isResolved: Bool {
|
||||
if case .resolved = self { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
var result: HistoricalGameResult? {
|
||||
if case .resolved(let result) = self { return result }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Score API Provider Protocol
|
||||
|
||||
protocol ScoreAPIProvider: Sendable {
|
||||
var name: String { get }
|
||||
var supportedSports: Set<Sport> { get }
|
||||
var reliability: ProviderReliability { get }
|
||||
var rateLimitKey: String { get }
|
||||
|
||||
func fetchGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult?
|
||||
}
|
||||
|
||||
// MARK: - Provider Errors
|
||||
|
||||
enum ScoreProviderError: Error, LocalizedError, Sendable {
|
||||
case networkError(underlying: String)
|
||||
case rateLimited
|
||||
case parseError(message: String)
|
||||
case gameNotFound
|
||||
case unsupportedSport(Sport)
|
||||
case providerUnavailable(reason: String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .networkError(let underlying):
|
||||
return "Network error: \(underlying)"
|
||||
case .rateLimited:
|
||||
return "Rate limited by provider"
|
||||
case .parseError(let message):
|
||||
return "Failed to parse response: \(message)"
|
||||
case .gameNotFound:
|
||||
return "Game not found"
|
||||
case .unsupportedSport(let sport):
|
||||
return "\(sport.rawValue) not supported by this provider"
|
||||
case .providerUnavailable(let reason):
|
||||
return "Provider unavailable: \(reason)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Free Score API Orchestrator
|
||||
|
||||
@MainActor
|
||||
final class FreeScoreAPI {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
static let shared = FreeScoreAPI()
|
||||
|
||||
private var providers: [ScoreAPIProvider] = []
|
||||
private var disabledProviders: [String: Date] = [:] // provider → disabled until
|
||||
private var failureCounts: [String: Int] = [:]
|
||||
|
||||
// Failure thresholds
|
||||
private let officialFailureThreshold = Int.max // Never auto-disable
|
||||
private let unofficialFailureThreshold = 3
|
||||
private let scrapedFailureThreshold = 2
|
||||
private let disableDuration: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||
private let failureWindowDuration: TimeInterval = 60 * 60 // 1 hour
|
||||
|
||||
private let rateLimiter = RateLimiter.shared
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private init() {
|
||||
// Register providers in priority order
|
||||
registerDefaultProviders()
|
||||
}
|
||||
|
||||
private func registerDefaultProviders() {
|
||||
// Official APIs first (most reliable)
|
||||
providers.append(MLBStatsProvider())
|
||||
providers.append(NHLStatsProvider())
|
||||
providers.append(NBAStatsProvider())
|
||||
|
||||
// Note: ESPN provider could be added here as unofficial fallback
|
||||
// Note: Sports-Reference scraper could be added as last resort
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Register a custom provider
|
||||
func registerProvider(_ provider: ScoreAPIProvider) {
|
||||
providers.append(provider)
|
||||
}
|
||||
|
||||
/// Resolve score for a game query
|
||||
/// Tries each provider in order: official > unofficial > scraped
|
||||
func resolveScore(query: HistoricalGameQuery) async -> ScoreResolutionResult {
|
||||
// Filter providers that support this sport
|
||||
let eligibleProviders = providers.filter {
|
||||
$0.supportedSports.contains(query.sport) && !isDisabled($0)
|
||||
}
|
||||
|
||||
guard !eligibleProviders.isEmpty else {
|
||||
return .requiresUserInput(reason: "No providers available for \(query.sport.rawValue)")
|
||||
}
|
||||
|
||||
// Sort by reliability (official first)
|
||||
let sortedProviders = eligibleProviders.sorted { p1, p2 in
|
||||
reliabilityOrder(p1.reliability) < reliabilityOrder(p2.reliability)
|
||||
}
|
||||
|
||||
// Try each provider in order
|
||||
for provider in sortedProviders {
|
||||
do {
|
||||
// Wait for rate limit
|
||||
await rateLimiter.waitIfNeeded(for: provider.rateLimitKey)
|
||||
|
||||
// Attempt fetch
|
||||
if let result = try await provider.fetchGame(query: query) {
|
||||
// Success - reset failure count
|
||||
resetFailureCount(for: provider)
|
||||
return .resolved(result)
|
||||
}
|
||||
} catch {
|
||||
// Record failure
|
||||
recordFailure(for: provider, error: error)
|
||||
|
||||
// Continue to next provider if this one failed
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// All providers failed or returned nil
|
||||
return .notFound(reason: "Game not found in any provider for \(query.sport.rawValue) on \(query.normalizedDateString)")
|
||||
}
|
||||
|
||||
/// Check if a provider is available
|
||||
func isProviderAvailable(_ providerName: String) -> Bool {
|
||||
guard let provider = providers.first(where: { $0.name == providerName }) else {
|
||||
return false
|
||||
}
|
||||
return !isDisabled(provider)
|
||||
}
|
||||
|
||||
/// Get list of available providers for a sport
|
||||
func availableProviders(for sport: Sport) -> [String] {
|
||||
providers
|
||||
.filter { $0.supportedSports.contains(sport) && !isDisabled($0) }
|
||||
.map { $0.name }
|
||||
}
|
||||
|
||||
/// Manually re-enable a disabled provider
|
||||
func enableProvider(_ providerName: String) {
|
||||
disabledProviders.removeValue(forKey: providerName)
|
||||
failureCounts.removeValue(forKey: providerName)
|
||||
}
|
||||
|
||||
/// Manually disable a provider
|
||||
func disableProvider(_ providerName: String, until date: Date) {
|
||||
disabledProviders[providerName] = date
|
||||
}
|
||||
|
||||
// MARK: - Provider Management
|
||||
|
||||
private func isDisabled(_ provider: ScoreAPIProvider) -> Bool {
|
||||
guard let disabledUntil = disabledProviders[provider.name] else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if disable period has expired
|
||||
if Date() > disabledUntil {
|
||||
disabledProviders.removeValue(forKey: provider.name)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private func recordFailure(for provider: ScoreAPIProvider, error: Error) {
|
||||
let count = (failureCounts[provider.name] ?? 0) + 1
|
||||
failureCounts[provider.name] = count
|
||||
|
||||
// Check if should auto-disable
|
||||
let threshold = failureThreshold(for: provider.reliability)
|
||||
|
||||
if count >= threshold {
|
||||
let disableUntil = Date().addingTimeInterval(disableDuration)
|
||||
disabledProviders[provider.name] = disableUntil
|
||||
failureCounts.removeValue(forKey: provider.name)
|
||||
}
|
||||
}
|
||||
|
||||
private func resetFailureCount(for provider: ScoreAPIProvider) {
|
||||
failureCounts.removeValue(forKey: provider.name)
|
||||
}
|
||||
|
||||
private func failureThreshold(for reliability: ProviderReliability) -> Int {
|
||||
switch reliability {
|
||||
case .official:
|
||||
return officialFailureThreshold
|
||||
case .unofficial:
|
||||
return unofficialFailureThreshold
|
||||
case .scraped:
|
||||
return scrapedFailureThreshold
|
||||
}
|
||||
}
|
||||
|
||||
private func reliabilityOrder(_ reliability: ProviderReliability) -> Int {
|
||||
switch reliability {
|
||||
case .official: return 0
|
||||
case .unofficial: return 1
|
||||
case .scraped: return 2
|
||||
}
|
||||
}
|
||||
}
|
||||
324
SportsTime/Core/Services/GameMatcher.swift
Normal file
324
SportsTime/Core/Services/GameMatcher.swift
Normal file
@@ -0,0 +1,324 @@
|
||||
//
|
||||
// GameMatcher.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Deterministic game matching from photo metadata.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
// MARK: - No Match Reason
|
||||
|
||||
enum NoMatchReason: Sendable {
|
||||
case noStadiumNearby
|
||||
case noGamesOnDate
|
||||
case metadataMissing(MetadataMissingReason)
|
||||
|
||||
enum MetadataMissingReason: Sendable {
|
||||
case noLocation
|
||||
case noDate
|
||||
case noBoth
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .noStadiumNearby:
|
||||
return "No stadium found nearby"
|
||||
case .noGamesOnDate:
|
||||
return "No games found on this date"
|
||||
case .metadataMissing(let reason):
|
||||
switch reason {
|
||||
case .noLocation:
|
||||
return "Photo has no location data"
|
||||
case .noDate:
|
||||
return "Photo has no date information"
|
||||
case .noBoth:
|
||||
return "Photo has no location or date data"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Game Match Result
|
||||
|
||||
struct GameMatchCandidate: Identifiable, Sendable {
|
||||
let id: UUID
|
||||
let game: Game
|
||||
let stadium: Stadium
|
||||
let homeTeam: Team
|
||||
let awayTeam: Team
|
||||
let confidence: PhotoMatchConfidence
|
||||
|
||||
init(game: Game, stadium: Stadium, homeTeam: Team, awayTeam: Team, confidence: PhotoMatchConfidence) {
|
||||
self.id = game.id
|
||||
self.game = game
|
||||
self.stadium = stadium
|
||||
self.homeTeam = homeTeam
|
||||
self.awayTeam = awayTeam
|
||||
self.confidence = confidence
|
||||
}
|
||||
|
||||
var matchupDescription: String {
|
||||
"\(awayTeam.abbreviation) @ \(homeTeam.abbreviation)"
|
||||
}
|
||||
|
||||
var fullMatchupDescription: String {
|
||||
"\(awayTeam.fullName) at \(homeTeam.fullName)"
|
||||
}
|
||||
|
||||
var gameDateTime: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: game.dateTime)
|
||||
}
|
||||
}
|
||||
|
||||
enum GameMatchResult: Sendable {
|
||||
case singleMatch(GameMatchCandidate) // Auto-select
|
||||
case multipleMatches([GameMatchCandidate]) // User selects (doubleheader, nearby stadiums)
|
||||
case noMatches(NoMatchReason) // Manual entry required
|
||||
|
||||
var hasMatch: Bool {
|
||||
switch self {
|
||||
case .singleMatch, .multipleMatches:
|
||||
return true
|
||||
case .noMatches:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Photo Import Result
|
||||
|
||||
struct PhotoImportCandidate: Identifiable, Sendable {
|
||||
let id: UUID
|
||||
let metadata: PhotoMetadata
|
||||
let matchResult: GameMatchResult
|
||||
let stadiumMatches: [StadiumMatch]
|
||||
|
||||
init(metadata: PhotoMetadata, matchResult: GameMatchResult, stadiumMatches: [StadiumMatch]) {
|
||||
self.id = UUID()
|
||||
self.metadata = metadata
|
||||
self.matchResult = matchResult
|
||||
self.stadiumMatches = stadiumMatches
|
||||
}
|
||||
|
||||
/// Best stadium match if available
|
||||
var bestStadiumMatch: StadiumMatch? {
|
||||
stadiumMatches.first
|
||||
}
|
||||
|
||||
/// Whether this can be auto-processed without user input
|
||||
var canAutoProcess: Bool {
|
||||
if case .singleMatch(let candidate) = matchResult {
|
||||
return candidate.confidence.combined == .autoSelect
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Game Matcher
|
||||
|
||||
@MainActor
|
||||
final class GameMatcher {
|
||||
static let shared = GameMatcher()
|
||||
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
private let proximityMatcher = StadiumProximityMatcher.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Primary Matching
|
||||
|
||||
/// Match photo metadata to a game
|
||||
/// Uses deterministic rules - never guesses
|
||||
func matchGame(
|
||||
metadata: PhotoMetadata,
|
||||
sport: Sport? = nil
|
||||
) async -> GameMatchResult {
|
||||
// 1. Check for required metadata
|
||||
guard metadata.hasValidLocation else {
|
||||
let reason: NoMatchReason.MetadataMissingReason = metadata.hasValidDate ? .noLocation : .noBoth
|
||||
return .noMatches(.metadataMissing(reason))
|
||||
}
|
||||
|
||||
guard metadata.hasValidDate, let photoDate = metadata.captureDate else {
|
||||
return .noMatches(.metadataMissing(.noDate))
|
||||
}
|
||||
|
||||
guard let coordinates = metadata.coordinates else {
|
||||
return .noMatches(.metadataMissing(.noLocation))
|
||||
}
|
||||
|
||||
// 2. Find nearby stadiums
|
||||
let stadiumMatches = proximityMatcher.findNearbyStadiums(
|
||||
coordinates: coordinates,
|
||||
sport: sport
|
||||
)
|
||||
|
||||
guard !stadiumMatches.isEmpty else {
|
||||
return .noMatches(.noStadiumNearby)
|
||||
}
|
||||
|
||||
// 3. Find games at those stadiums on/around that date
|
||||
var candidates: [GameMatchCandidate] = []
|
||||
|
||||
for stadiumMatch in stadiumMatches {
|
||||
let games = await findGames(
|
||||
at: stadiumMatch.stadium,
|
||||
around: photoDate,
|
||||
sport: sport
|
||||
)
|
||||
|
||||
for game in games {
|
||||
// Look up teams
|
||||
guard let homeTeam = dataProvider.teams.first(where: { $0.id == game.homeTeamId }),
|
||||
let awayTeam = dataProvider.teams.first(where: { $0.id == game.awayTeamId }) else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate confidence
|
||||
let confidence = proximityMatcher.calculateMatchConfidence(
|
||||
stadiumMatch: stadiumMatch,
|
||||
photoDate: photoDate,
|
||||
gameDate: game.dateTime
|
||||
)
|
||||
|
||||
// Only include if temporal confidence is acceptable
|
||||
if confidence.temporal != .outOfRange {
|
||||
candidates.append(GameMatchCandidate(
|
||||
game: game,
|
||||
stadium: stadiumMatch.stadium,
|
||||
homeTeam: homeTeam,
|
||||
awayTeam: awayTeam,
|
||||
confidence: confidence
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Return based on matches found
|
||||
if candidates.isEmpty {
|
||||
return .noMatches(.noGamesOnDate)
|
||||
} else if candidates.count == 1 {
|
||||
return .singleMatch(candidates[0])
|
||||
} else {
|
||||
// Sort by confidence (best first)
|
||||
let sorted = candidates.sorted { c1, c2 in
|
||||
c1.confidence.combined > c2.confidence.combined
|
||||
}
|
||||
return .multipleMatches(sorted)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Full Import Processing
|
||||
|
||||
/// Process a photo for import, returning full match context
|
||||
func processPhotoForImport(
|
||||
metadata: PhotoMetadata,
|
||||
sport: Sport? = nil
|
||||
) async -> PhotoImportCandidate {
|
||||
// Get stadium matches regardless of game matching
|
||||
var stadiumMatches: [StadiumMatch] = []
|
||||
if let coordinates = metadata.coordinates {
|
||||
stadiumMatches = proximityMatcher.findNearbyStadiums(
|
||||
coordinates: coordinates,
|
||||
sport: sport
|
||||
)
|
||||
}
|
||||
|
||||
let matchResult = await matchGame(metadata: metadata, sport: sport)
|
||||
|
||||
return PhotoImportCandidate(
|
||||
metadata: metadata,
|
||||
matchResult: matchResult,
|
||||
stadiumMatches: stadiumMatches
|
||||
)
|
||||
}
|
||||
|
||||
/// Process multiple photos for import
|
||||
func processPhotosForImport(
|
||||
_ metadataList: [PhotoMetadata],
|
||||
sport: Sport? = nil
|
||||
) async -> [PhotoImportCandidate] {
|
||||
var results: [PhotoImportCandidate] = []
|
||||
|
||||
for metadata in metadataList {
|
||||
let candidate = await processPhotoForImport(metadata: metadata, sport: sport)
|
||||
results.append(candidate)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Find games at a stadium around a given date (±1 day for timezone/tailgating)
|
||||
private func findGames(
|
||||
at stadium: Stadium,
|
||||
around date: Date,
|
||||
sport: Sport?
|
||||
) async -> [Game] {
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Search window: ±1 day
|
||||
guard let startDate = calendar.date(byAdding: .day, value: -1, to: date),
|
||||
let endDate = calendar.date(byAdding: .day, value: 2, to: date) else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Determine which sports to query
|
||||
let sports: Set<Sport> = sport != nil ? [sport!] : Set(Sport.allCases)
|
||||
|
||||
do {
|
||||
let allGames = try await dataProvider.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
|
||||
|
||||
// Filter by stadium
|
||||
let games = allGames.filter { $0.stadiumId == stadium.id }
|
||||
|
||||
return games
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Batch Processing Helpers
|
||||
|
||||
extension GameMatcher {
|
||||
/// Separate photos into categories for UI
|
||||
struct CategorizedImports: Sendable {
|
||||
let autoProcessable: [PhotoImportCandidate]
|
||||
let needsConfirmation: [PhotoImportCandidate]
|
||||
let needsManualEntry: [PhotoImportCandidate]
|
||||
}
|
||||
|
||||
nonisolated func categorizeImports(_ candidates: [PhotoImportCandidate]) -> CategorizedImports {
|
||||
var auto: [PhotoImportCandidate] = []
|
||||
var confirm: [PhotoImportCandidate] = []
|
||||
var manual: [PhotoImportCandidate] = []
|
||||
|
||||
for candidate in candidates {
|
||||
switch candidate.matchResult {
|
||||
case .singleMatch(let match):
|
||||
if match.confidence.combined == .autoSelect {
|
||||
auto.append(candidate)
|
||||
} else {
|
||||
confirm.append(candidate)
|
||||
}
|
||||
case .multipleMatches:
|
||||
confirm.append(candidate)
|
||||
case .noMatches:
|
||||
manual.append(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
return CategorizedImports(
|
||||
autoProcessable: auto,
|
||||
needsConfirmation: confirm,
|
||||
needsManualEntry: manual
|
||||
)
|
||||
}
|
||||
}
|
||||
200
SportsTime/Core/Services/PhotoMetadataExtractor.swift
Normal file
200
SportsTime/Core/Services/PhotoMetadataExtractor.swift
Normal file
@@ -0,0 +1,200 @@
|
||||
//
|
||||
// PhotoMetadataExtractor.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Service for extracting EXIF metadata (GPS, date) from photos.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Photos
|
||||
import CoreLocation
|
||||
import ImageIO
|
||||
import UIKit
|
||||
|
||||
// MARK: - Photo Metadata
|
||||
|
||||
struct PhotoMetadata: Sendable {
|
||||
let captureDate: Date?
|
||||
let coordinates: CLLocationCoordinate2D?
|
||||
let hasValidLocation: Bool
|
||||
let hasValidDate: Bool
|
||||
|
||||
nonisolated init(captureDate: Date?, coordinates: CLLocationCoordinate2D?) {
|
||||
self.captureDate = captureDate
|
||||
self.coordinates = coordinates
|
||||
self.hasValidLocation = coordinates != nil
|
||||
self.hasValidDate = captureDate != nil
|
||||
}
|
||||
|
||||
nonisolated static var empty: PhotoMetadata {
|
||||
PhotoMetadata(captureDate: nil, coordinates: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Photo Metadata Extractor
|
||||
|
||||
actor PhotoMetadataExtractor {
|
||||
static let shared = PhotoMetadataExtractor()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - PHAsset Extraction (Preferred)
|
||||
|
||||
/// Extract metadata from PHAsset (preferred method)
|
||||
/// Uses PHAsset's location and creationDate properties
|
||||
func extractMetadata(from asset: PHAsset) async -> PhotoMetadata {
|
||||
// PHAsset provides location and date directly
|
||||
let coordinates: CLLocationCoordinate2D?
|
||||
if let location = asset.location {
|
||||
coordinates = location.coordinate
|
||||
} else {
|
||||
coordinates = nil
|
||||
}
|
||||
|
||||
return PhotoMetadata(
|
||||
captureDate: asset.creationDate,
|
||||
coordinates: coordinates
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract metadata from multiple PHAssets
|
||||
func extractMetadata(from assets: [PHAsset]) async -> [PhotoMetadata] {
|
||||
var results: [PhotoMetadata] = []
|
||||
for asset in assets {
|
||||
let metadata = await extractMetadata(from: asset)
|
||||
results.append(metadata)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
// MARK: - Image Data Extraction (Fallback)
|
||||
|
||||
/// Extract metadata from raw image data using ImageIO
|
||||
/// Useful when PHAsset is not available
|
||||
func extractMetadata(from imageData: Data) -> PhotoMetadata {
|
||||
guard let source = CGImageSourceCreateWithData(imageData as CFData, nil),
|
||||
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else {
|
||||
return .empty
|
||||
}
|
||||
|
||||
let captureDate = extractDate(from: properties)
|
||||
let coordinates = extractCoordinates(from: properties)
|
||||
|
||||
return PhotoMetadata(
|
||||
captureDate: captureDate,
|
||||
coordinates: coordinates
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func extractDate(from properties: [CFString: Any]) -> Date? {
|
||||
// Try EXIF DateTimeOriginal first
|
||||
if let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any],
|
||||
let dateString = exif[kCGImagePropertyExifDateTimeOriginal] as? String {
|
||||
return parseExifDate(dateString)
|
||||
}
|
||||
|
||||
// Try TIFF DateTime
|
||||
if let tiff = properties[kCGImagePropertyTIFFDictionary] as? [CFString: Any],
|
||||
let dateString = tiff[kCGImagePropertyTIFFDateTime] as? String {
|
||||
return parseExifDate(dateString)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func extractCoordinates(from properties: [CFString: Any]) -> CLLocationCoordinate2D? {
|
||||
guard let gps = properties[kCGImagePropertyGPSDictionary] as? [CFString: Any],
|
||||
let latitude = gps[kCGImagePropertyGPSLatitude] as? Double,
|
||||
let longitude = gps[kCGImagePropertyGPSLongitude] as? Double else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle N/S and E/W references
|
||||
var lat = latitude
|
||||
var lon = longitude
|
||||
|
||||
if let latRef = gps[kCGImagePropertyGPSLatitudeRef] as? String, latRef == "S" {
|
||||
lat = -lat
|
||||
}
|
||||
|
||||
if let lonRef = gps[kCGImagePropertyGPSLongitudeRef] as? String, lonRef == "W" {
|
||||
lon = -lon
|
||||
}
|
||||
|
||||
// Validate coordinates
|
||||
guard lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
|
||||
}
|
||||
|
||||
private func parseExifDate(_ dateString: String) -> Date? {
|
||||
// EXIF date format: "2024:06:15 14:30:00"
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy:MM:dd HH:mm:ss"
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
return formatter.date(from: dateString)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Photo Library Access
|
||||
|
||||
extension PhotoMetadataExtractor {
|
||||
/// Request photo library access
|
||||
@MainActor
|
||||
func requestPhotoLibraryAccess() async -> PHAuthorizationStatus {
|
||||
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
return await PHPhotoLibrary.requestAuthorization(for: .readWrite)
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if photo library access is available
|
||||
var hasPhotoLibraryAccess: Bool {
|
||||
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
return status == .authorized || status == .limited
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Asset Image Loading
|
||||
|
||||
extension PhotoMetadataExtractor {
|
||||
/// Load thumbnail image from PHAsset
|
||||
func loadThumbnail(from asset: PHAsset, targetSize: CGSize = CGSize(width: 200, height: 200)) async -> UIImage? {
|
||||
await withCheckedContinuation { continuation in
|
||||
let options = PHImageRequestOptions()
|
||||
options.deliveryMode = .fastFormat
|
||||
options.resizeMode = .fast
|
||||
options.isSynchronous = false
|
||||
|
||||
PHImageManager.default().requestImage(
|
||||
for: asset,
|
||||
targetSize: targetSize,
|
||||
contentMode: .aspectFill,
|
||||
options: options
|
||||
) { image, _ in
|
||||
continuation.resume(returning: image)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load full-size image data from PHAsset
|
||||
func loadImageData(from asset: PHAsset) async -> Data? {
|
||||
await withCheckedContinuation { continuation in
|
||||
let options = PHImageRequestOptions()
|
||||
options.deliveryMode = .highQualityFormat
|
||||
options.isSynchronous = false
|
||||
|
||||
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, _, _, _ in
|
||||
continuation.resume(returning: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
208
SportsTime/Core/Services/RateLimiter.swift
Normal file
208
SportsTime/Core/Services/RateLimiter.swift
Normal file
@@ -0,0 +1,208 @@
|
||||
//
|
||||
// RateLimiter.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Rate limiting for API providers to respect their rate limits.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Rate Limiter
|
||||
|
||||
/// Per-provider rate limiting to avoid hitting API limits
|
||||
actor RateLimiter {
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
struct ProviderConfig {
|
||||
let name: String
|
||||
let minInterval: TimeInterval // Minimum time between requests
|
||||
let burstLimit: Int // Max requests in burst window
|
||||
let burstWindow: TimeInterval // Window for burst counting
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var lastRequestTimes: [String: Date] = [:]
|
||||
private var requestCounts: [String: [Date]] = [:]
|
||||
private var configs: [String: ProviderConfig] = [:]
|
||||
|
||||
// MARK: - Default Configurations
|
||||
|
||||
/// Default provider rate limit configurations
|
||||
private static let defaultConfigs: [ProviderConfig] = [
|
||||
ProviderConfig(name: "mlb_stats", minInterval: 0.1, burstLimit: 30, burstWindow: 60), // 10 req/sec
|
||||
ProviderConfig(name: "nhl_stats", minInterval: 0.2, burstLimit: 20, burstWindow: 60), // 5 req/sec
|
||||
ProviderConfig(name: "nba_stats", minInterval: 0.5, burstLimit: 10, burstWindow: 60), // 2 req/sec
|
||||
ProviderConfig(name: "espn", minInterval: 1.0, burstLimit: 30, burstWindow: 60), // 1 req/sec
|
||||
ProviderConfig(name: "sports_reference", minInterval: 3.0, burstLimit: 10, burstWindow: 60) // 1 req/3 sec
|
||||
]
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = RateLimiter()
|
||||
|
||||
private init() {
|
||||
// Load default configs
|
||||
for config in Self.defaultConfigs {
|
||||
configs[config.name] = config
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Configure rate limiting for a provider
|
||||
func configureProvider(_ config: ProviderConfig) {
|
||||
configs[config.name] = config
|
||||
}
|
||||
|
||||
// MARK: - Rate Limiting
|
||||
|
||||
/// Wait if needed to respect rate limits for a provider
|
||||
/// Returns immediately if rate limit allows, otherwise sleeps until allowed
|
||||
func waitIfNeeded(for provider: String) async {
|
||||
let config = configs[provider] ?? ProviderConfig(
|
||||
name: provider,
|
||||
minInterval: 1.0,
|
||||
burstLimit: 60,
|
||||
burstWindow: 60
|
||||
)
|
||||
|
||||
await enforceMinInterval(for: provider, interval: config.minInterval)
|
||||
await enforceBurstLimit(for: provider, limit: config.burstLimit, window: config.burstWindow)
|
||||
|
||||
recordRequest(for: provider)
|
||||
}
|
||||
|
||||
/// Check if a request can be made without waiting
|
||||
func canMakeRequest(for provider: String) -> Bool {
|
||||
let config = configs[provider] ?? ProviderConfig(
|
||||
name: provider,
|
||||
minInterval: 1.0,
|
||||
burstLimit: 60,
|
||||
burstWindow: 60
|
||||
)
|
||||
|
||||
// Check min interval
|
||||
if let lastRequest = lastRequestTimes[provider] {
|
||||
let elapsed = Date().timeIntervalSince(lastRequest)
|
||||
if elapsed < config.minInterval {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Check burst limit
|
||||
let now = Date()
|
||||
let windowStart = now.addingTimeInterval(-config.burstWindow)
|
||||
|
||||
if let requests = requestCounts[provider] {
|
||||
let recentRequests = requests.filter { $0 > windowStart }
|
||||
if recentRequests.count >= config.burstLimit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/// Get estimated wait time until next request is allowed
|
||||
func estimatedWaitTime(for provider: String) -> TimeInterval {
|
||||
let config = configs[provider] ?? ProviderConfig(
|
||||
name: provider,
|
||||
minInterval: 1.0,
|
||||
burstLimit: 60,
|
||||
burstWindow: 60
|
||||
)
|
||||
|
||||
var maxWait: TimeInterval = 0
|
||||
|
||||
// Check min interval wait
|
||||
if let lastRequest = lastRequestTimes[provider] {
|
||||
let elapsed = Date().timeIntervalSince(lastRequest)
|
||||
if elapsed < config.minInterval {
|
||||
maxWait = max(maxWait, config.minInterval - elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
// Check burst limit wait
|
||||
let now = Date()
|
||||
let windowStart = now.addingTimeInterval(-config.burstWindow)
|
||||
|
||||
if let requests = requestCounts[provider] {
|
||||
let recentRequests = requests.filter { $0 > windowStart }.sorted()
|
||||
if recentRequests.count >= config.burstLimit {
|
||||
// Need to wait until oldest request falls out of window
|
||||
if let oldestInWindow = recentRequests.first {
|
||||
let waitUntil = oldestInWindow.addingTimeInterval(config.burstWindow)
|
||||
let wait = waitUntil.timeIntervalSince(now)
|
||||
maxWait = max(maxWait, wait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxWait
|
||||
}
|
||||
|
||||
/// Reset rate limit tracking for a provider
|
||||
func reset(for provider: String) {
|
||||
lastRequestTimes.removeValue(forKey: provider)
|
||||
requestCounts.removeValue(forKey: provider)
|
||||
}
|
||||
|
||||
/// Reset all rate limit tracking
|
||||
func resetAll() {
|
||||
lastRequestTimes.removeAll()
|
||||
requestCounts.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func enforceMinInterval(for provider: String, interval: TimeInterval) async {
|
||||
if let lastRequest = lastRequestTimes[provider] {
|
||||
let elapsed = Date().timeIntervalSince(lastRequest)
|
||||
if elapsed < interval {
|
||||
let waitTime = interval - elapsed
|
||||
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func enforceBurstLimit(for provider: String, limit: Int, window: TimeInterval) async {
|
||||
let now = Date()
|
||||
let windowStart = now.addingTimeInterval(-window)
|
||||
|
||||
// Clean up old requests
|
||||
if var requests = requestCounts[provider] {
|
||||
requests = requests.filter { $0 > windowStart }
|
||||
requestCounts[provider] = requests
|
||||
|
||||
// Check if at limit
|
||||
if requests.count >= limit {
|
||||
// Wait until oldest request falls out of window
|
||||
if let oldestInWindow = requests.sorted().first {
|
||||
let waitUntil = oldestInWindow.addingTimeInterval(window)
|
||||
let waitTime = waitUntil.timeIntervalSince(now)
|
||||
if waitTime > 0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func recordRequest(for provider: String) {
|
||||
let now = Date()
|
||||
lastRequestTimes[provider] = now
|
||||
|
||||
if requestCounts[provider] == nil {
|
||||
requestCounts[provider] = []
|
||||
}
|
||||
requestCounts[provider]?.append(now)
|
||||
|
||||
// Clean up old requests periodically
|
||||
if let requests = requestCounts[provider], requests.count > 1000 {
|
||||
let oneHourAgo = now.addingTimeInterval(-3600)
|
||||
requestCounts[provider] = requests.filter { $0 > oneHourAgo }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// MLBStatsProvider.swift
|
||||
// SportsTime
|
||||
//
|
||||
// MLB Stats API provider - official, documented, stable.
|
||||
// API: https://statsapi.mlb.com
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - MLB Stats Provider
|
||||
|
||||
struct MLBStatsProvider: ScoreAPIProvider {
|
||||
|
||||
// MARK: - Protocol Requirements
|
||||
|
||||
let name = "MLB Stats API"
|
||||
let supportedSports: Set<Sport> = [.mlb]
|
||||
let reliability: ProviderReliability = .official
|
||||
let rateLimitKey = "mlb_stats"
|
||||
|
||||
// MARK: - API Configuration
|
||||
|
||||
private let baseURL = "https://statsapi.mlb.com/api/v1"
|
||||
|
||||
// MARK: - Team ID Mapping
|
||||
|
||||
/// Maps team abbreviations to MLB Stats API team IDs
|
||||
private static let teamIdMapping: [String: Int] = [
|
||||
"ARI": 109, "ATL": 144, "BAL": 110, "BOS": 111,
|
||||
"CHC": 112, "CWS": 145, "CIN": 113, "CLE": 114,
|
||||
"COL": 115, "DET": 116, "HOU": 117, "KC": 118,
|
||||
"LAA": 108, "LAD": 119, "MIA": 146, "MIL": 158,
|
||||
"MIN": 142, "NYM": 121, "NYY": 147, "OAK": 133,
|
||||
"PHI": 143, "PIT": 134, "SD": 135, "SF": 137,
|
||||
"SEA": 136, "STL": 138, "TB": 139, "TEX": 140,
|
||||
"TOR": 141, "WSH": 120
|
||||
]
|
||||
|
||||
// Reverse mapping for API response
|
||||
private static let idToAbbrevMapping: [Int: String] = {
|
||||
Dictionary(uniqueKeysWithValues: teamIdMapping.map { ($1, $0) })
|
||||
}()
|
||||
|
||||
// MARK: - Fetch Game
|
||||
|
||||
func fetchGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult? {
|
||||
guard query.sport == .mlb else {
|
||||
throw ScoreProviderError.unsupportedSport(query.sport)
|
||||
}
|
||||
|
||||
// Build schedule URL for the date
|
||||
let dateString = query.normalizedDateString
|
||||
let urlString = "\(baseURL)/schedule?sportId=1&date=\(dateString)&hydrate=team,linescore"
|
||||
|
||||
guard let url = URL(string: urlString) else {
|
||||
throw ScoreProviderError.networkError(underlying: "Invalid URL")
|
||||
}
|
||||
|
||||
// Fetch data
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
|
||||
// Check HTTP response
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw ScoreProviderError.networkError(underlying: "Invalid response type")
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
if httpResponse.statusCode == 429 {
|
||||
throw ScoreProviderError.rateLimited
|
||||
}
|
||||
throw ScoreProviderError.networkError(underlying: "HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
// Parse response
|
||||
return try parseScheduleResponse(data: data, query: query)
|
||||
}
|
||||
|
||||
// MARK: - Response Parsing
|
||||
|
||||
private func parseScheduleResponse(data: Data, query: HistoricalGameQuery) throws -> HistoricalGameResult? {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let dates = json["dates"] as? [[String: Any]] else {
|
||||
throw ScoreProviderError.parseError(message: "Invalid JSON structure")
|
||||
}
|
||||
|
||||
// Find games on the requested date
|
||||
for dateEntry in dates {
|
||||
guard let games = dateEntry["games"] as? [[String: Any]] else { continue }
|
||||
|
||||
for game in games {
|
||||
// Extract team info
|
||||
guard let teams = game["teams"] as? [String: Any],
|
||||
let homeTeamData = teams["home"] as? [String: Any],
|
||||
let awayTeamData = teams["away"] as? [String: Any],
|
||||
let homeTeam = homeTeamData["team"] as? [String: Any],
|
||||
let awayTeam = awayTeamData["team"] as? [String: Any],
|
||||
let homeTeamId = homeTeam["id"] as? Int,
|
||||
let awayTeamId = awayTeam["id"] as? Int else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get team abbreviations
|
||||
guard let homeAbbrev = Self.idToAbbrevMapping[homeTeamId],
|
||||
let awayAbbrev = Self.idToAbbrevMapping[awayTeamId] else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this matches the query
|
||||
if let queryHome = query.homeTeamAbbrev, queryHome.uppercased() != homeAbbrev {
|
||||
continue
|
||||
}
|
||||
if let queryAway = query.awayTeamAbbrev, queryAway.uppercased() != awayAbbrev {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract team names
|
||||
let homeTeamName = homeTeam["name"] as? String ?? homeAbbrev
|
||||
let awayTeamName = awayTeam["name"] as? String ?? awayAbbrev
|
||||
|
||||
// Extract scores from linescore if available
|
||||
var homeScore: Int?
|
||||
var awayScore: Int?
|
||||
|
||||
if let linescore = game["linescore"] as? [String: Any],
|
||||
let lineTeams = linescore["teams"] as? [String: Any] {
|
||||
if let homeLineData = lineTeams["home"] as? [String: Any] {
|
||||
homeScore = homeLineData["runs"] as? Int
|
||||
}
|
||||
if let awayLineData = lineTeams["away"] as? [String: Any] {
|
||||
awayScore = awayLineData["runs"] as? Int
|
||||
}
|
||||
}
|
||||
|
||||
// Alternative: get scores from team data
|
||||
if homeScore == nil, let score = homeTeamData["score"] as? Int {
|
||||
homeScore = score
|
||||
}
|
||||
if awayScore == nil, let score = awayTeamData["score"] as? Int {
|
||||
awayScore = score
|
||||
}
|
||||
|
||||
// Extract game date
|
||||
let gameDate: Date
|
||||
if let gameDateString = game["gameDate"] as? String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
gameDate = formatter.date(from: gameDateString) ?? query.date
|
||||
} else {
|
||||
gameDate = query.date
|
||||
}
|
||||
|
||||
return HistoricalGameResult(
|
||||
sport: .mlb,
|
||||
gameDate: gameDate,
|
||||
homeTeamAbbrev: homeAbbrev,
|
||||
awayTeamAbbrev: awayAbbrev,
|
||||
homeTeamName: homeTeamName,
|
||||
awayTeamName: awayTeamName,
|
||||
homeScore: homeScore,
|
||||
awayScore: awayScore,
|
||||
source: .api,
|
||||
providerName: name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sendable Conformance
|
||||
|
||||
extension MLBStatsProvider: Sendable {}
|
||||
@@ -0,0 +1,215 @@
|
||||
//
|
||||
// NBAStatsProvider.swift
|
||||
// SportsTime
|
||||
//
|
||||
// NBA Stats API provider - unofficial but functional.
|
||||
// API: https://stats.nba.com
|
||||
// Note: Requires specific headers, may break without notice.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - NBA Stats Provider
|
||||
|
||||
struct NBAStatsProvider: ScoreAPIProvider {
|
||||
|
||||
// MARK: - Protocol Requirements
|
||||
|
||||
let name = "NBA Stats API"
|
||||
let supportedSports: Set<Sport> = [.nba]
|
||||
let reliability: ProviderReliability = .unofficial
|
||||
let rateLimitKey = "nba_stats"
|
||||
|
||||
// MARK: - API Configuration
|
||||
|
||||
private let baseURL = "https://stats.nba.com/stats"
|
||||
|
||||
// Required headers to avoid 403 errors
|
||||
private let requiredHeaders: [String: String] = [
|
||||
"Host": "stats.nba.com",
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Accept-Language": "en-US,en;q=0.5",
|
||||
"Referer": "https://www.nba.com/",
|
||||
"x-nba-stats-origin": "stats",
|
||||
"x-nba-stats-token": "true",
|
||||
"Connection": "keep-alive"
|
||||
]
|
||||
|
||||
// MARK: - Team ID Mapping
|
||||
|
||||
/// Maps team abbreviations to NBA Stats API team IDs
|
||||
private static let teamIdMapping: [String: Int] = [
|
||||
"ATL": 1610612737, "BOS": 1610612738, "BKN": 1610612751, "CHA": 1610612766,
|
||||
"CHI": 1610612741, "CLE": 1610612739, "DAL": 1610612742, "DEN": 1610612743,
|
||||
"DET": 1610612765, "GSW": 1610612744, "HOU": 1610612745, "IND": 1610612754,
|
||||
"LAC": 1610612746, "LAL": 1610612747, "MEM": 1610612763, "MIA": 1610612748,
|
||||
"MIL": 1610612749, "MIN": 1610612750, "NOP": 1610612740, "NYK": 1610612752,
|
||||
"OKC": 1610612760, "ORL": 1610612753, "PHI": 1610612755, "PHX": 1610612756,
|
||||
"POR": 1610612757, "SAC": 1610612758, "SAS": 1610612759, "TOR": 1610612761,
|
||||
"UTA": 1610612762, "WAS": 1610612764
|
||||
]
|
||||
|
||||
// Reverse mapping
|
||||
private static let idToAbbrevMapping: [Int: String] = {
|
||||
Dictionary(uniqueKeysWithValues: teamIdMapping.map { ($1, $0) })
|
||||
}()
|
||||
|
||||
// Team names
|
||||
private static let teamNames: [String: String] = [
|
||||
"ATL": "Atlanta Hawks", "BOS": "Boston Celtics", "BKN": "Brooklyn Nets",
|
||||
"CHA": "Charlotte Hornets", "CHI": "Chicago Bulls", "CLE": "Cleveland Cavaliers",
|
||||
"DAL": "Dallas Mavericks", "DEN": "Denver Nuggets", "DET": "Detroit Pistons",
|
||||
"GSW": "Golden State Warriors", "HOU": "Houston Rockets", "IND": "Indiana Pacers",
|
||||
"LAC": "Los Angeles Clippers", "LAL": "Los Angeles Lakers", "MEM": "Memphis Grizzlies",
|
||||
"MIA": "Miami Heat", "MIL": "Milwaukee Bucks", "MIN": "Minnesota Timberwolves",
|
||||
"NOP": "New Orleans Pelicans", "NYK": "New York Knicks", "OKC": "Oklahoma City Thunder",
|
||||
"ORL": "Orlando Magic", "PHI": "Philadelphia 76ers", "PHX": "Phoenix Suns",
|
||||
"POR": "Portland Trail Blazers", "SAC": "Sacramento Kings", "SAS": "San Antonio Spurs",
|
||||
"TOR": "Toronto Raptors", "UTA": "Utah Jazz", "WAS": "Washington Wizards"
|
||||
]
|
||||
|
||||
// MARK: - Fetch Game
|
||||
|
||||
func fetchGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult? {
|
||||
guard query.sport == .nba else {
|
||||
throw ScoreProviderError.unsupportedSport(query.sport)
|
||||
}
|
||||
|
||||
// Build scoreboard URL for the date
|
||||
let dateString = query.normalizedDateString.replacingOccurrences(of: "-", with: "")
|
||||
let urlString = "\(baseURL)/scoreboardv2?GameDate=\(dateString)&LeagueID=00&DayOffset=0"
|
||||
|
||||
guard let url = URL(string: urlString) else {
|
||||
throw ScoreProviderError.networkError(underlying: "Invalid URL")
|
||||
}
|
||||
|
||||
// Create request with required headers
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
for (key, value) in requiredHeaders {
|
||||
request.setValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
// Fetch data
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
// Check HTTP response
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw ScoreProviderError.networkError(underlying: "Invalid response type")
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
if httpResponse.statusCode == 429 {
|
||||
throw ScoreProviderError.rateLimited
|
||||
}
|
||||
if httpResponse.statusCode == 403 {
|
||||
throw ScoreProviderError.providerUnavailable(reason: "Access denied - headers may need update")
|
||||
}
|
||||
throw ScoreProviderError.networkError(underlying: "HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
// Parse response
|
||||
return try parseScoreboardResponse(data: data, query: query)
|
||||
}
|
||||
|
||||
// MARK: - Response Parsing
|
||||
|
||||
private func parseScoreboardResponse(data: Data, query: HistoricalGameQuery) throws -> HistoricalGameResult? {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let resultSets = json["resultSets"] as? [[String: Any]] else {
|
||||
throw ScoreProviderError.parseError(message: "Invalid JSON structure")
|
||||
}
|
||||
|
||||
// Find the GameHeader result set
|
||||
guard let gameHeaderSet = resultSets.first(where: { ($0["name"] as? String) == "GameHeader" }),
|
||||
let headers = gameHeaderSet["headers"] as? [String],
|
||||
let rowSet = gameHeaderSet["rowSet"] as? [[Any]] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get column indices
|
||||
let homeTeamIdIdx = headers.firstIndex(of: "HOME_TEAM_ID")
|
||||
let visitorTeamIdIdx = headers.firstIndex(of: "VISITOR_TEAM_ID")
|
||||
let gameStatusIdx = headers.firstIndex(of: "GAME_STATUS_TEXT")
|
||||
|
||||
// Find the LineScore result set for scores
|
||||
let lineScoreSet = resultSets.first(where: { ($0["name"] as? String) == "LineScore" })
|
||||
let lineScoreHeaders = lineScoreSet?["headers"] as? [String]
|
||||
let lineScoreRows = lineScoreSet?["rowSet"] as? [[Any]]
|
||||
let teamIdScoreIdx = lineScoreHeaders?.firstIndex(of: "TEAM_ID")
|
||||
let ptsIdx = lineScoreHeaders?.firstIndex(of: "PTS")
|
||||
|
||||
// Process each game
|
||||
for row in rowSet {
|
||||
guard let homeTeamIdIdx = homeTeamIdIdx,
|
||||
let visitorTeamIdIdx = visitorTeamIdIdx,
|
||||
homeTeamIdIdx < row.count,
|
||||
visitorTeamIdIdx < row.count,
|
||||
let homeTeamId = row[homeTeamIdIdx] as? Int,
|
||||
let awayTeamId = row[visitorTeamIdIdx] as? Int else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get team abbreviations
|
||||
guard let homeAbbrev = Self.idToAbbrevMapping[homeTeamId],
|
||||
let awayAbbrev = Self.idToAbbrevMapping[awayTeamId] else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this matches the query
|
||||
if let queryHome = query.homeTeamAbbrev, queryHome.uppercased() != homeAbbrev {
|
||||
continue
|
||||
}
|
||||
if let queryAway = query.awayTeamAbbrev, queryAway.uppercased() != awayAbbrev {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get team names
|
||||
let homeTeamName = Self.teamNames[homeAbbrev] ?? homeAbbrev
|
||||
let awayTeamName = Self.teamNames[awayAbbrev] ?? awayAbbrev
|
||||
|
||||
// Get scores from LineScore
|
||||
var homeScore: Int?
|
||||
var awayScore: Int?
|
||||
|
||||
if let lineScoreRows = lineScoreRows,
|
||||
let teamIdScoreIdx = teamIdScoreIdx,
|
||||
let ptsIdx = ptsIdx {
|
||||
|
||||
for scoreRow in lineScoreRows {
|
||||
guard teamIdScoreIdx < scoreRow.count,
|
||||
ptsIdx < scoreRow.count,
|
||||
let teamId = scoreRow[teamIdScoreIdx] as? Int else {
|
||||
continue
|
||||
}
|
||||
|
||||
if teamId == homeTeamId {
|
||||
homeScore = scoreRow[ptsIdx] as? Int
|
||||
} else if teamId == awayTeamId {
|
||||
awayScore = scoreRow[ptsIdx] as? Int
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return HistoricalGameResult(
|
||||
sport: .nba,
|
||||
gameDate: query.date,
|
||||
homeTeamAbbrev: homeAbbrev,
|
||||
awayTeamAbbrev: awayAbbrev,
|
||||
homeTeamName: homeTeamName,
|
||||
awayTeamName: awayTeamName,
|
||||
homeScore: homeScore,
|
||||
awayScore: awayScore,
|
||||
source: .api,
|
||||
providerName: name
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sendable Conformance
|
||||
|
||||
extension NBAStatsProvider: Sendable {}
|
||||
@@ -0,0 +1,172 @@
|
||||
//
|
||||
// NHLStatsProvider.swift
|
||||
// SportsTime
|
||||
//
|
||||
// NHL Stats API provider - official, documented, stable.
|
||||
// API: https://api-web.nhle.com
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - NHL Stats Provider
|
||||
|
||||
struct NHLStatsProvider: ScoreAPIProvider {
|
||||
|
||||
// MARK: - Protocol Requirements
|
||||
|
||||
let name = "NHL Stats API"
|
||||
let supportedSports: Set<Sport> = [.nhl]
|
||||
let reliability: ProviderReliability = .official
|
||||
let rateLimitKey = "nhl_stats"
|
||||
|
||||
// MARK: - API Configuration
|
||||
|
||||
private let baseURL = "https://api-web.nhle.com/v1"
|
||||
|
||||
// MARK: - Team Abbreviation Mapping
|
||||
|
||||
/// Maps common team abbreviations to NHL API team codes
|
||||
private static let teamAbbrevMapping: [String: String] = [
|
||||
"ANA": "ANA", "ARI": "ARI", "BOS": "BOS", "BUF": "BUF",
|
||||
"CGY": "CGY", "CAR": "CAR", "CHI": "CHI", "COL": "COL",
|
||||
"CBJ": "CBJ", "DAL": "DAL", "DET": "DET", "EDM": "EDM",
|
||||
"FLA": "FLA", "LA": "LAK", "LAK": "LAK", "MIN": "MIN",
|
||||
"MTL": "MTL", "NSH": "NSH", "NJ": "NJD", "NJD": "NJD",
|
||||
"NYI": "NYI", "NYR": "NYR", "OTT": "OTT", "PHI": "PHI",
|
||||
"PIT": "PIT", "SJ": "SJS", "SJS": "SJS", "SEA": "SEA",
|
||||
"STL": "STL", "TB": "TBL", "TBL": "TBL", "TOR": "TOR",
|
||||
"UTA": "UTA", "VAN": "VAN", "VGK": "VGK", "WSH": "WSH",
|
||||
"WPG": "WPG"
|
||||
]
|
||||
|
||||
// Team names for display
|
||||
private static let teamNames: [String: String] = [
|
||||
"ANA": "Anaheim Ducks", "ARI": "Arizona Coyotes", "BOS": "Boston Bruins",
|
||||
"BUF": "Buffalo Sabres", "CGY": "Calgary Flames", "CAR": "Carolina Hurricanes",
|
||||
"CHI": "Chicago Blackhawks", "COL": "Colorado Avalanche",
|
||||
"CBJ": "Columbus Blue Jackets", "DAL": "Dallas Stars", "DET": "Detroit Red Wings",
|
||||
"EDM": "Edmonton Oilers", "FLA": "Florida Panthers", "LAK": "Los Angeles Kings",
|
||||
"MIN": "Minnesota Wild", "MTL": "Montreal Canadiens", "NSH": "Nashville Predators",
|
||||
"NJD": "New Jersey Devils", "NYI": "New York Islanders", "NYR": "New York Rangers",
|
||||
"OTT": "Ottawa Senators", "PHI": "Philadelphia Flyers", "PIT": "Pittsburgh Penguins",
|
||||
"SJS": "San Jose Sharks", "SEA": "Seattle Kraken", "STL": "St. Louis Blues",
|
||||
"TBL": "Tampa Bay Lightning", "TOR": "Toronto Maple Leafs", "UTA": "Utah Hockey Club",
|
||||
"VAN": "Vancouver Canucks", "VGK": "Vegas Golden Knights",
|
||||
"WSH": "Washington Capitals", "WPG": "Winnipeg Jets"
|
||||
]
|
||||
|
||||
// MARK: - Fetch Game
|
||||
|
||||
func fetchGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult? {
|
||||
guard query.sport == .nhl else {
|
||||
throw ScoreProviderError.unsupportedSport(query.sport)
|
||||
}
|
||||
|
||||
// Build schedule URL for the date
|
||||
let dateString = query.normalizedDateString
|
||||
let urlString = "\(baseURL)/schedule/\(dateString)"
|
||||
|
||||
guard let url = URL(string: urlString) else {
|
||||
throw ScoreProviderError.networkError(underlying: "Invalid URL")
|
||||
}
|
||||
|
||||
// Fetch data
|
||||
let (data, response) = try await URLSession.shared.data(from: url)
|
||||
|
||||
// Check HTTP response
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw ScoreProviderError.networkError(underlying: "Invalid response type")
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
if httpResponse.statusCode == 429 {
|
||||
throw ScoreProviderError.rateLimited
|
||||
}
|
||||
throw ScoreProviderError.networkError(underlying: "HTTP \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
// Parse response
|
||||
return try parseScheduleResponse(data: data, query: query)
|
||||
}
|
||||
|
||||
// MARK: - Response Parsing
|
||||
|
||||
private func parseScheduleResponse(data: Data, query: HistoricalGameQuery) throws -> HistoricalGameResult? {
|
||||
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let gameWeek = json["gameWeek"] as? [[String: Any]] else {
|
||||
throw ScoreProviderError.parseError(message: "Invalid JSON structure")
|
||||
}
|
||||
|
||||
// Find games on the requested date
|
||||
for dayEntry in gameWeek {
|
||||
guard let games = dayEntry["games"] as? [[String: Any]] else { continue }
|
||||
|
||||
for game in games {
|
||||
// Extract team info
|
||||
guard let homeTeam = game["homeTeam"] as? [String: Any],
|
||||
let awayTeam = game["awayTeam"] as? [String: Any],
|
||||
let homeAbbrevRaw = homeTeam["abbrev"] as? String,
|
||||
let awayAbbrevRaw = awayTeam["abbrev"] as? String else {
|
||||
continue
|
||||
}
|
||||
|
||||
// Normalize abbreviations
|
||||
let homeAbbrev = Self.teamAbbrevMapping[homeAbbrevRaw.uppercased()] ?? homeAbbrevRaw.uppercased()
|
||||
let awayAbbrev = Self.teamAbbrevMapping[awayAbbrevRaw.uppercased()] ?? awayAbbrevRaw.uppercased()
|
||||
|
||||
// Check if this matches the query
|
||||
if let queryHome = query.homeTeamAbbrev {
|
||||
let normalizedQueryHome = Self.teamAbbrevMapping[queryHome.uppercased()] ?? queryHome.uppercased()
|
||||
if normalizedQueryHome != homeAbbrev {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if let queryAway = query.awayTeamAbbrev {
|
||||
let normalizedQueryAway = Self.teamAbbrevMapping[queryAway.uppercased()] ?? queryAway.uppercased()
|
||||
if normalizedQueryAway != awayAbbrev {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Extract team names
|
||||
let homeTeamName = homeTeam["placeName"] as? [String: Any]
|
||||
let homeTeamNameDefault = (homeTeamName?["default"] as? String) ?? Self.teamNames[homeAbbrev] ?? homeAbbrev
|
||||
|
||||
let awayTeamName = awayTeam["placeName"] as? [String: Any]
|
||||
let awayTeamNameDefault = (awayTeamName?["default"] as? String) ?? Self.teamNames[awayAbbrev] ?? awayAbbrev
|
||||
|
||||
// Extract scores
|
||||
let homeScore = homeTeam["score"] as? Int
|
||||
let awayScore = awayTeam["score"] as? Int
|
||||
|
||||
// Extract game date
|
||||
let gameDate: Date
|
||||
if let startTime = game["startTimeUTC"] as? String {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
gameDate = formatter.date(from: startTime) ?? query.date
|
||||
} else {
|
||||
gameDate = query.date
|
||||
}
|
||||
|
||||
return HistoricalGameResult(
|
||||
sport: .nhl,
|
||||
gameDate: gameDate,
|
||||
homeTeamAbbrev: homeAbbrev,
|
||||
awayTeamAbbrev: awayAbbrev,
|
||||
homeTeamName: homeTeamNameDefault,
|
||||
awayTeamName: awayTeamNameDefault,
|
||||
homeScore: homeScore,
|
||||
awayScore: awayScore,
|
||||
source: .api,
|
||||
providerName: name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sendable Conformance
|
||||
|
||||
extension NHLStatsProvider: Sendable {}
|
||||
312
SportsTime/Core/Services/ScoreResolutionCache.swift
Normal file
312
SportsTime/Core/Services/ScoreResolutionCache.swift
Normal file
@@ -0,0 +1,312 @@
|
||||
//
|
||||
// ScoreResolutionCache.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Manages caching of resolved game scores using SwiftData.
|
||||
// Historical scores never change, so they can be cached indefinitely.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Score Resolution Cache
|
||||
|
||||
@MainActor
|
||||
final class ScoreResolutionCache {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let modelContext: ModelContext
|
||||
|
||||
// Cache configuration
|
||||
private static let recentGameCacheDuration: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||
private static let failedLookupCacheDuration: TimeInterval = 7 * 24 * 60 * 60 // 7 days
|
||||
private static let historicalAgeThreshold: TimeInterval = 30 * 24 * 60 * 60 // 30 days
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(modelContext: ModelContext) {
|
||||
self.modelContext = modelContext
|
||||
}
|
||||
|
||||
// MARK: - Cache Operations
|
||||
|
||||
/// Get cached score for a game query
|
||||
func getCached(query: HistoricalGameQuery) -> CachedGameScore? {
|
||||
guard let homeAbbrev = query.homeTeamAbbrev,
|
||||
let awayAbbrev = query.awayTeamAbbrev else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let cacheKey = CachedGameScore.generateKey(
|
||||
sport: query.sport,
|
||||
date: query.date,
|
||||
homeAbbrev: homeAbbrev,
|
||||
awayAbbrev: awayAbbrev
|
||||
)
|
||||
|
||||
let descriptor = FetchDescriptor<CachedGameScore>(
|
||||
predicate: #Predicate { $0.cacheKey == cacheKey }
|
||||
)
|
||||
|
||||
do {
|
||||
let results = try modelContext.fetch(descriptor)
|
||||
if let cached = results.first {
|
||||
// Check if expired
|
||||
if cached.isExpired {
|
||||
// Delete expired entry
|
||||
modelContext.delete(cached)
|
||||
try? modelContext.save()
|
||||
return nil
|
||||
}
|
||||
return cached
|
||||
}
|
||||
} catch {
|
||||
// Fetch failed, return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Convert cached score to HistoricalGameResult
|
||||
func getCachedResult(query: HistoricalGameQuery) -> HistoricalGameResult? {
|
||||
guard let cached = getCached(query: query) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return HistoricalGameResult(
|
||||
sport: cached.sportEnum ?? query.sport,
|
||||
gameDate: cached.gameDate,
|
||||
homeTeamAbbrev: cached.homeTeamAbbrev,
|
||||
awayTeamAbbrev: cached.awayTeamAbbrev,
|
||||
homeTeamName: cached.homeTeamName,
|
||||
awayTeamName: cached.awayTeamName,
|
||||
homeScore: cached.homeScore,
|
||||
awayScore: cached.awayScore,
|
||||
source: cached.scoreSource,
|
||||
providerName: "cache"
|
||||
)
|
||||
}
|
||||
|
||||
/// Cache a resolved game result
|
||||
func cache(result: HistoricalGameResult, query: HistoricalGameQuery) {
|
||||
let cacheKey = CachedGameScore.generateKey(
|
||||
sport: result.sport,
|
||||
date: result.gameDate,
|
||||
homeAbbrev: result.homeTeamAbbrev,
|
||||
awayAbbrev: result.awayTeamAbbrev
|
||||
)
|
||||
|
||||
// Check if already cached
|
||||
let descriptor = FetchDescriptor<CachedGameScore>(
|
||||
predicate: #Predicate { $0.cacheKey == cacheKey }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if let existingEntry = existing.first {
|
||||
// Update existing entry
|
||||
existingEntry.homeScore = result.homeScore
|
||||
existingEntry.awayScore = result.awayScore
|
||||
existingEntry.sourceRaw = result.source.rawValue
|
||||
existingEntry.fetchedAt = Date()
|
||||
existingEntry.expiresAt = calculateExpiration(for: result.gameDate)
|
||||
} else {
|
||||
// Create new entry
|
||||
let cached = CachedGameScore(
|
||||
cacheKey: cacheKey,
|
||||
sport: result.sport,
|
||||
gameDate: result.gameDate,
|
||||
homeTeamAbbrev: result.homeTeamAbbrev,
|
||||
awayTeamAbbrev: result.awayTeamAbbrev,
|
||||
homeTeamName: result.homeTeamName,
|
||||
awayTeamName: result.awayTeamName,
|
||||
homeScore: result.homeScore,
|
||||
awayScore: result.awayScore,
|
||||
source: result.source,
|
||||
expiresAt: calculateExpiration(for: result.gameDate)
|
||||
)
|
||||
modelContext.insert(cached)
|
||||
}
|
||||
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
// Cache save failed, continue without caching
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache a failed lookup to avoid repeated failures
|
||||
func cacheFailedLookup(query: HistoricalGameQuery) {
|
||||
guard let homeAbbrev = query.homeTeamAbbrev,
|
||||
let awayAbbrev = query.awayTeamAbbrev else {
|
||||
return
|
||||
}
|
||||
|
||||
let cacheKey = CachedGameScore.generateKey(
|
||||
sport: query.sport,
|
||||
date: query.date,
|
||||
homeAbbrev: homeAbbrev,
|
||||
awayAbbrev: awayAbbrev
|
||||
)
|
||||
|
||||
// Check if already cached
|
||||
let descriptor = FetchDescriptor<CachedGameScore>(
|
||||
predicate: #Predicate { $0.cacheKey == cacheKey }
|
||||
)
|
||||
|
||||
do {
|
||||
let existing = try modelContext.fetch(descriptor)
|
||||
if existing.isEmpty {
|
||||
// Create failed lookup entry (no scores)
|
||||
let cached = CachedGameScore(
|
||||
cacheKey: cacheKey,
|
||||
sport: query.sport,
|
||||
gameDate: query.date,
|
||||
homeTeamAbbrev: homeAbbrev,
|
||||
awayTeamAbbrev: awayAbbrev,
|
||||
homeTeamName: homeAbbrev,
|
||||
awayTeamName: awayAbbrev,
|
||||
homeScore: nil,
|
||||
awayScore: nil,
|
||||
source: .api,
|
||||
expiresAt: Date().addingTimeInterval(Self.failedLookupCacheDuration)
|
||||
)
|
||||
modelContext.insert(cached)
|
||||
try modelContext.save()
|
||||
}
|
||||
} catch {
|
||||
// Ignore cache failures
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove a cached entry
|
||||
func invalidate(query: HistoricalGameQuery) {
|
||||
guard let homeAbbrev = query.homeTeamAbbrev,
|
||||
let awayAbbrev = query.awayTeamAbbrev else {
|
||||
return
|
||||
}
|
||||
|
||||
let cacheKey = CachedGameScore.generateKey(
|
||||
sport: query.sport,
|
||||
date: query.date,
|
||||
homeAbbrev: homeAbbrev,
|
||||
awayAbbrev: awayAbbrev
|
||||
)
|
||||
|
||||
let descriptor = FetchDescriptor<CachedGameScore>(
|
||||
predicate: #Predicate { $0.cacheKey == cacheKey }
|
||||
)
|
||||
|
||||
do {
|
||||
let results = try modelContext.fetch(descriptor)
|
||||
for entry in results {
|
||||
modelContext.delete(entry)
|
||||
}
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
// Ignore deletion failures
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up expired cache entries
|
||||
func cleanupExpired() {
|
||||
let now = Date()
|
||||
|
||||
// Can't use date comparison directly in predicate with non-nil check
|
||||
// Fetch all and filter
|
||||
let descriptor = FetchDescriptor<CachedGameScore>()
|
||||
|
||||
do {
|
||||
let allCached = try modelContext.fetch(descriptor)
|
||||
var deletedCount = 0
|
||||
|
||||
for entry in allCached {
|
||||
if let expiresAt = entry.expiresAt, expiresAt < now {
|
||||
modelContext.delete(entry)
|
||||
deletedCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
if deletedCount > 0 {
|
||||
try modelContext.save()
|
||||
}
|
||||
} catch {
|
||||
// Cleanup failed, will try again later
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cache statistics
|
||||
func getCacheStats() -> CacheStats {
|
||||
let descriptor = FetchDescriptor<CachedGameScore>()
|
||||
|
||||
do {
|
||||
let all = try modelContext.fetch(descriptor)
|
||||
let now = Date()
|
||||
|
||||
var withScores = 0
|
||||
var withoutScores = 0
|
||||
var expired = 0
|
||||
var bySport: [Sport: Int] = [:]
|
||||
|
||||
for entry in all {
|
||||
// Count by sport
|
||||
if let sport = entry.sportEnum {
|
||||
bySport[sport, default: 0] += 1
|
||||
}
|
||||
|
||||
// Count with/without scores
|
||||
if entry.homeScore != nil && entry.awayScore != nil {
|
||||
withScores += 1
|
||||
} else {
|
||||
withoutScores += 1
|
||||
}
|
||||
|
||||
// Count expired
|
||||
if let expiresAt = entry.expiresAt, expiresAt < now {
|
||||
expired += 1
|
||||
}
|
||||
}
|
||||
|
||||
return CacheStats(
|
||||
totalEntries: all.count,
|
||||
entriesWithScores: withScores,
|
||||
entriesWithoutScores: withoutScores,
|
||||
expiredEntries: expired,
|
||||
entriesBySport: bySport
|
||||
)
|
||||
} catch {
|
||||
return CacheStats(
|
||||
totalEntries: 0,
|
||||
entriesWithScores: 0,
|
||||
entriesWithoutScores: 0,
|
||||
expiredEntries: 0,
|
||||
entriesBySport: [:]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private func calculateExpiration(for gameDate: Date) -> Date? {
|
||||
let now = Date()
|
||||
let gameAge = now.timeIntervalSince(gameDate)
|
||||
|
||||
if gameAge > Self.historicalAgeThreshold {
|
||||
// Historical games never expire
|
||||
return nil
|
||||
} else {
|
||||
// Recent games expire after 24 hours
|
||||
return now.addingTimeInterval(Self.recentGameCacheDuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cache Statistics
|
||||
|
||||
struct CacheStats {
|
||||
let totalEntries: Int
|
||||
let entriesWithScores: Int
|
||||
let entriesWithoutScores: Int
|
||||
let expiredEntries: Int
|
||||
let entriesBySport: [Sport: Int]
|
||||
}
|
||||
273
SportsTime/Core/Services/StadiumIdentityService.swift
Normal file
273
SportsTime/Core/Services/StadiumIdentityService.swift
Normal file
@@ -0,0 +1,273 @@
|
||||
//
|
||||
// StadiumIdentityService.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Service for resolving stadium identities across renames and aliases.
|
||||
// Wraps CanonicalStadium lookups from SwiftData.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
// MARK: - Stadium Identity Service
|
||||
|
||||
/// Resolves stadium identities to canonical IDs, handling renames and aliases.
|
||||
/// Example: "SBC Park", "AT&T Park", and "Oracle Park" all resolve to the same canonical ID.
|
||||
actor StadiumIdentityService {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = StadiumIdentityService()
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private var modelContainer: ModelContainer?
|
||||
|
||||
// Cache for performance
|
||||
private var uuidToCanonicalId: [UUID: String] = [:]
|
||||
private var canonicalIdToUUID: [String: UUID] = [:]
|
||||
private var nameToCanonicalId: [String: String] = [:]
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
/// Configure the service with a model container
|
||||
func configure(with container: ModelContainer) {
|
||||
self.modelContainer = container
|
||||
invalidateCache()
|
||||
}
|
||||
|
||||
// MARK: - Public Methods
|
||||
|
||||
/// Get the canonical ID for a stadium UUID
|
||||
/// Returns the same canonicalId for stadiums that are the same physical location
|
||||
func canonicalId(for stadiumUUID: UUID) async throws -> String? {
|
||||
// Check cache first
|
||||
if let cached = uuidToCanonicalId[stadiumUUID] {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let container = modelContainer else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let context = ModelContext(container)
|
||||
|
||||
let descriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate<CanonicalStadium> { stadium in
|
||||
stadium.uuid == stadiumUUID
|
||||
}
|
||||
)
|
||||
|
||||
guard let stadium = try context.fetch(descriptor).first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
uuidToCanonicalId[stadiumUUID] = stadium.canonicalId
|
||||
canonicalIdToUUID[stadium.canonicalId] = stadium.uuid
|
||||
|
||||
return stadium.canonicalId
|
||||
}
|
||||
|
||||
/// Get the canonical ID for a stadium name (searches aliases too)
|
||||
func canonicalId(forName name: String) async throws -> String? {
|
||||
let lowercasedName = name.lowercased()
|
||||
|
||||
// Check cache first
|
||||
if let cached = nameToCanonicalId[lowercasedName] {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let container = modelContainer else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let context = ModelContext(container)
|
||||
|
||||
// First check stadium aliases
|
||||
let aliasDescriptor = FetchDescriptor<StadiumAlias>(
|
||||
predicate: #Predicate<StadiumAlias> { alias in
|
||||
alias.aliasName == lowercasedName
|
||||
}
|
||||
)
|
||||
|
||||
if let alias = try context.fetch(aliasDescriptor).first {
|
||||
nameToCanonicalId[lowercasedName] = alias.stadiumCanonicalId
|
||||
return alias.stadiumCanonicalId
|
||||
}
|
||||
|
||||
// Fall back to direct stadium name match
|
||||
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>()
|
||||
let stadiums = try context.fetch(stadiumDescriptor)
|
||||
|
||||
// Case-insensitive match on stadium name
|
||||
if let stadium = stadiums.first(where: { $0.name.lowercased() == lowercasedName }) {
|
||||
nameToCanonicalId[lowercasedName] = stadium.canonicalId
|
||||
return stadium.canonicalId
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Check if two stadium UUIDs represent the same physical stadium
|
||||
func isSameStadium(_ id1: UUID, _ id2: UUID) async throws -> Bool {
|
||||
guard let canonicalId1 = try await canonicalId(for: id1),
|
||||
let canonicalId2 = try await canonicalId(for: id2) else {
|
||||
// If we can't resolve, fall back to direct comparison
|
||||
return id1 == id2
|
||||
}
|
||||
return canonicalId1 == canonicalId2
|
||||
}
|
||||
|
||||
/// Get the current UUID for a canonical stadium ID
|
||||
func currentUUID(forCanonicalId canonicalId: String) async throws -> UUID? {
|
||||
// Check cache first
|
||||
if let cached = canonicalIdToUUID[canonicalId] {
|
||||
return cached
|
||||
}
|
||||
|
||||
guard let container = modelContainer else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let context = ModelContext(container)
|
||||
|
||||
let descriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate<CanonicalStadium> { stadium in
|
||||
stadium.canonicalId == canonicalId && stadium.deprecatedAt == nil
|
||||
}
|
||||
)
|
||||
|
||||
guard let stadium = try context.fetch(descriptor).first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
canonicalIdToUUID[canonicalId] = stadium.uuid
|
||||
uuidToCanonicalId[stadium.uuid] = stadium.canonicalId
|
||||
|
||||
return stadium.uuid
|
||||
}
|
||||
|
||||
/// Get the current name for a canonical stadium ID
|
||||
func currentName(forCanonicalId canonicalId: String) async throws -> String? {
|
||||
guard let container = modelContainer else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let context = ModelContext(container)
|
||||
|
||||
let descriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate<CanonicalStadium> { stadium in
|
||||
stadium.canonicalId == canonicalId && stadium.deprecatedAt == nil
|
||||
}
|
||||
)
|
||||
|
||||
guard let stadium = try context.fetch(descriptor).first else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return stadium.name
|
||||
}
|
||||
|
||||
/// Get all historical names for a stadium
|
||||
func allNames(forCanonicalId canonicalId: String) async throws -> [String] {
|
||||
guard let container = modelContainer else {
|
||||
return []
|
||||
}
|
||||
|
||||
let context = ModelContext(container)
|
||||
|
||||
// Get aliases
|
||||
let aliasDescriptor = FetchDescriptor<StadiumAlias>(
|
||||
predicate: #Predicate<StadiumAlias> { alias in
|
||||
alias.stadiumCanonicalId == canonicalId
|
||||
}
|
||||
)
|
||||
|
||||
let aliases = try context.fetch(aliasDescriptor)
|
||||
var names = aliases.map { $0.aliasName }
|
||||
|
||||
// Add current name
|
||||
if let currentName = try await currentName(forCanonicalId: canonicalId) {
|
||||
if !names.contains(currentName.lowercased()) {
|
||||
names.append(currentName)
|
||||
}
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
/// Find stadium by approximate location (for photo import)
|
||||
func findStadium(near latitude: Double, longitude: Double, radiusMeters: Double = 5000) async throws -> CanonicalStadium? {
|
||||
guard let container = modelContainer else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let context = ModelContext(container)
|
||||
|
||||
// Fetch all active stadiums and filter by distance
|
||||
let descriptor = FetchDescriptor<CanonicalStadium>(
|
||||
predicate: #Predicate<CanonicalStadium> { stadium in
|
||||
stadium.deprecatedAt == nil
|
||||
}
|
||||
)
|
||||
|
||||
let stadiums = try context.fetch(descriptor)
|
||||
|
||||
// Calculate approximate degree ranges for the radius
|
||||
// At equator: 1 degree ≈ 111km, so radiusMeters / 111000 gives degrees
|
||||
let degreeDelta = radiusMeters / 111000.0
|
||||
|
||||
let nearbyStadiums = stadiums.filter { stadium in
|
||||
abs(stadium.latitude - latitude) <= degreeDelta &&
|
||||
abs(stadium.longitude - longitude) <= degreeDelta * 1.5 // Account for longitude compression at higher latitudes
|
||||
}
|
||||
|
||||
// If multiple stadiums nearby, find the closest
|
||||
guard !nearbyStadiums.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if nearbyStadiums.count == 1 {
|
||||
return nearbyStadiums.first
|
||||
}
|
||||
|
||||
// Calculate actual distances for the nearby stadiums
|
||||
return nearbyStadiums.min { s1, s2 in
|
||||
let d1 = haversineDistance(lat1: latitude, lon1: longitude, lat2: s1.latitude, lon2: s1.longitude)
|
||||
let d2 = haversineDistance(lat1: latitude, lon1: longitude, lat2: s2.latitude, lon2: s2.longitude)
|
||||
return d1 < d2
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cache Management
|
||||
|
||||
/// Invalidate all caches (call after sync)
|
||||
func invalidateCache() {
|
||||
uuidToCanonicalId.removeAll()
|
||||
canonicalIdToUUID.removeAll()
|
||||
nameToCanonicalId.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Calculate distance between two coordinates using Haversine formula
|
||||
nonisolated private func haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
|
||||
let R = 6371000.0 // Earth's radius in meters
|
||||
let phi1 = lat1 * .pi / 180
|
||||
let phi2 = lat2 * .pi / 180
|
||||
let deltaPhi = (lat2 - lat1) * .pi / 180
|
||||
let deltaLambda = (lon2 - lon1) * .pi / 180
|
||||
|
||||
let a = sin(deltaPhi / 2) * sin(deltaPhi / 2) +
|
||||
cos(phi1) * cos(phi2) * sin(deltaLambda / 2) * sin(deltaLambda / 2)
|
||||
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
|
||||
return R * c
|
||||
}
|
||||
}
|
||||
348
SportsTime/Core/Services/StadiumProximityMatcher.swift
Normal file
348
SportsTime/Core/Services/StadiumProximityMatcher.swift
Normal file
@@ -0,0 +1,348 @@
|
||||
//
|
||||
// StadiumProximityMatcher.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Service for matching GPS coordinates to nearby stadiums.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
// MARK: - Match Confidence
|
||||
|
||||
enum MatchConfidence: Sendable {
|
||||
case high // < 500m from stadium center
|
||||
case medium // 500m - 2km
|
||||
case low // 2km - 5km
|
||||
case none // > 5km or no coordinates
|
||||
|
||||
nonisolated var description: String {
|
||||
switch self {
|
||||
case .high: return "High (within 500m)"
|
||||
case .medium: return "Medium (500m - 2km)"
|
||||
case .low: return "Low (2km - 5km)"
|
||||
case .none: return "No match"
|
||||
}
|
||||
}
|
||||
|
||||
/// Should auto-select this match without user confirmation?
|
||||
nonisolated var shouldAutoSelect: Bool {
|
||||
switch self {
|
||||
case .high: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit nonisolated Equatable and Comparable conformance
|
||||
extension MatchConfidence: Equatable {
|
||||
nonisolated static func == (lhs: MatchConfidence, rhs: MatchConfidence) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.high, .high), (.medium, .medium), (.low, .low), (.none, .none):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension MatchConfidence: Comparable {
|
||||
nonisolated static func < (lhs: MatchConfidence, rhs: MatchConfidence) -> Bool {
|
||||
let order: [MatchConfidence] = [.none, .low, .medium, .high]
|
||||
guard let lhsIndex = order.firstIndex(of: lhs),
|
||||
let rhsIndex = order.firstIndex(of: rhs) else {
|
||||
return false
|
||||
}
|
||||
return lhsIndex < rhsIndex
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stadium Match
|
||||
|
||||
struct StadiumMatch: Identifiable, Sendable {
|
||||
let id: UUID
|
||||
let stadium: Stadium
|
||||
let distance: CLLocationDistance
|
||||
let confidence: MatchConfidence
|
||||
|
||||
init(stadium: Stadium, distance: CLLocationDistance) {
|
||||
self.id = stadium.id
|
||||
self.stadium = stadium
|
||||
self.distance = distance
|
||||
self.confidence = Self.calculateConfidence(for: distance)
|
||||
}
|
||||
|
||||
var formattedDistance: String {
|
||||
if distance < 1000 {
|
||||
return String(format: "%.0fm away", distance)
|
||||
} else {
|
||||
return String(format: "%.1f km away", distance / 1000)
|
||||
}
|
||||
}
|
||||
|
||||
private static func calculateConfidence(for distance: CLLocationDistance) -> MatchConfidence {
|
||||
switch distance {
|
||||
case 0..<500:
|
||||
return .high
|
||||
case 500..<2000:
|
||||
return .medium
|
||||
case 2000..<5000:
|
||||
return .low
|
||||
default:
|
||||
return .none
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Temporal Confidence
|
||||
|
||||
enum TemporalConfidence: Sendable {
|
||||
case exactDay // Same local date as game
|
||||
case adjacentDay // ±1 day (tailgating, next morning)
|
||||
case outOfRange // >1 day difference
|
||||
|
||||
nonisolated var description: String {
|
||||
switch self {
|
||||
case .exactDay: return "Same day"
|
||||
case .adjacentDay: return "Adjacent day (±1)"
|
||||
case .outOfRange: return "Out of range"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TemporalConfidence: Equatable {
|
||||
nonisolated static func == (lhs: TemporalConfidence, rhs: TemporalConfidence) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.exactDay, .exactDay), (.adjacentDay, .adjacentDay), (.outOfRange, .outOfRange):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TemporalConfidence: Comparable {
|
||||
nonisolated static func < (lhs: TemporalConfidence, rhs: TemporalConfidence) -> Bool {
|
||||
let order: [TemporalConfidence] = [.outOfRange, .adjacentDay, .exactDay]
|
||||
guard let lhsIndex = order.firstIndex(of: lhs),
|
||||
let rhsIndex = order.firstIndex(of: rhs) else {
|
||||
return false
|
||||
}
|
||||
return lhsIndex < rhsIndex
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Combined Confidence
|
||||
|
||||
enum CombinedConfidence: Sendable {
|
||||
case autoSelect // High spatial + exactDay → auto-select
|
||||
case userConfirm // Medium spatial OR adjacentDay → user confirms
|
||||
case manualOnly // Low spatial OR outOfRange → manual entry
|
||||
|
||||
nonisolated var description: String {
|
||||
switch self {
|
||||
case .autoSelect: return "Auto-select"
|
||||
case .userConfirm: return "Needs confirmation"
|
||||
case .manualOnly: return "Manual entry required"
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func combine(spatial: MatchConfidence, temporal: TemporalConfidence) -> CombinedConfidence {
|
||||
// Low spatial or out of range → manual only
|
||||
switch spatial {
|
||||
case .low, .none:
|
||||
return .manualOnly
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
switch temporal {
|
||||
case .outOfRange:
|
||||
return .manualOnly
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// High spatial + exact day → auto-select
|
||||
switch (spatial, temporal) {
|
||||
case (.high, .exactDay):
|
||||
return .autoSelect
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
// Everything else needs user confirmation
|
||||
return .userConfirm
|
||||
}
|
||||
}
|
||||
|
||||
extension CombinedConfidence: Equatable {
|
||||
nonisolated static func == (lhs: CombinedConfidence, rhs: CombinedConfidence) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.autoSelect, .autoSelect), (.userConfirm, .userConfirm), (.manualOnly, .manualOnly):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension CombinedConfidence: Comparable {
|
||||
nonisolated static func < (lhs: CombinedConfidence, rhs: CombinedConfidence) -> Bool {
|
||||
let order: [CombinedConfidence] = [.manualOnly, .userConfirm, .autoSelect]
|
||||
guard let lhsIndex = order.firstIndex(of: lhs),
|
||||
let rhsIndex = order.firstIndex(of: rhs) else {
|
||||
return false
|
||||
}
|
||||
return lhsIndex < rhsIndex
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Photo Match Confidence
|
||||
|
||||
struct PhotoMatchConfidence: Sendable {
|
||||
let spatial: MatchConfidence
|
||||
let temporal: TemporalConfidence
|
||||
let combined: CombinedConfidence
|
||||
|
||||
nonisolated init(spatial: MatchConfidence, temporal: TemporalConfidence) {
|
||||
self.spatial = spatial
|
||||
self.temporal = temporal
|
||||
self.combined = CombinedConfidence.combine(spatial: spatial, temporal: temporal)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stadium Proximity Matcher
|
||||
|
||||
@MainActor
|
||||
final class StadiumProximityMatcher {
|
||||
static let shared = StadiumProximityMatcher()
|
||||
|
||||
// Configuration constants
|
||||
static let highConfidenceRadius: CLLocationDistance = 500 // 500m
|
||||
static let mediumConfidenceRadius: CLLocationDistance = 2000 // 2km
|
||||
static let searchRadius: CLLocationDistance = 5000 // 5km default
|
||||
static let dateToleranceDays: Int = 1 // ±1 day for timezone/tailgating
|
||||
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Stadium Matching
|
||||
|
||||
/// Find stadiums within radius of coordinates
|
||||
func findNearbyStadiums(
|
||||
coordinates: CLLocationCoordinate2D,
|
||||
radius: CLLocationDistance = StadiumProximityMatcher.searchRadius,
|
||||
sport: Sport? = nil
|
||||
) -> [StadiumMatch] {
|
||||
let photoLocation = CLLocation(latitude: coordinates.latitude, longitude: coordinates.longitude)
|
||||
|
||||
var stadiums = dataProvider.stadiums
|
||||
|
||||
// Filter by sport if specified
|
||||
if let sport = sport {
|
||||
let sportTeams = dataProvider.teams.filter { $0.sport == sport }
|
||||
let stadiumIds = Set(sportTeams.map { $0.stadiumId })
|
||||
stadiums = stadiums.filter { stadiumIds.contains($0.id) }
|
||||
}
|
||||
|
||||
// Calculate distances and filter by radius
|
||||
var matches: [StadiumMatch] = []
|
||||
|
||||
for stadium in stadiums {
|
||||
let stadiumLocation = CLLocation(latitude: stadium.latitude, longitude: stadium.longitude)
|
||||
let distance = photoLocation.distance(from: stadiumLocation)
|
||||
|
||||
if distance <= radius {
|
||||
matches.append(StadiumMatch(stadium: stadium, distance: distance))
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by distance (closest first)
|
||||
return matches.sorted { $0.distance < $1.distance }
|
||||
}
|
||||
|
||||
/// Find best matching stadium (single result)
|
||||
func findBestMatch(
|
||||
coordinates: CLLocationCoordinate2D,
|
||||
sport: Sport? = nil
|
||||
) -> StadiumMatch? {
|
||||
let matches = findNearbyStadiums(coordinates: coordinates, sport: sport)
|
||||
return matches.first
|
||||
}
|
||||
|
||||
/// Check if coordinates are near any stadium
|
||||
func isNearStadium(
|
||||
coordinates: CLLocationCoordinate2D,
|
||||
radius: CLLocationDistance = StadiumProximityMatcher.searchRadius
|
||||
) -> Bool {
|
||||
let matches = findNearbyStadiums(coordinates: coordinates, radius: radius)
|
||||
return !matches.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Temporal Matching
|
||||
|
||||
/// Calculate temporal confidence between photo date and game date
|
||||
nonisolated func calculateTemporalConfidence(photoDate: Date, gameDate: Date) -> TemporalConfidence {
|
||||
let calendar = Calendar.current
|
||||
|
||||
// Normalize to day boundaries
|
||||
let photoDay = calendar.startOfDay(for: photoDate)
|
||||
let gameDay = calendar.startOfDay(for: gameDate)
|
||||
|
||||
let daysDifference = abs(calendar.dateComponents([.day], from: photoDay, to: gameDay).day ?? Int.max)
|
||||
|
||||
switch daysDifference {
|
||||
case 0:
|
||||
return .exactDay
|
||||
case 1:
|
||||
return .adjacentDay
|
||||
default:
|
||||
return .outOfRange
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate combined confidence for a photo-stadium-game match
|
||||
nonisolated func calculateMatchConfidence(
|
||||
stadiumMatch: StadiumMatch,
|
||||
photoDate: Date?,
|
||||
gameDate: Date?
|
||||
) -> PhotoMatchConfidence {
|
||||
let spatial = stadiumMatch.confidence
|
||||
|
||||
let temporal: TemporalConfidence
|
||||
if let photoDate = photoDate, let gameDate = gameDate {
|
||||
temporal = calculateTemporalConfidence(photoDate: photoDate, gameDate: gameDate)
|
||||
} else {
|
||||
// Missing date information
|
||||
temporal = .outOfRange
|
||||
}
|
||||
|
||||
return PhotoMatchConfidence(spatial: spatial, temporal: temporal)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Batch Processing
|
||||
|
||||
extension StadiumProximityMatcher {
|
||||
/// Find matches for multiple photos
|
||||
func findMatchesForPhotos(
|
||||
_ metadata: [PhotoMetadata],
|
||||
sport: Sport? = nil
|
||||
) -> [(metadata: PhotoMetadata, matches: [StadiumMatch])] {
|
||||
var results: [(metadata: PhotoMetadata, matches: [StadiumMatch])] = []
|
||||
|
||||
for photo in metadata {
|
||||
if let coordinates = photo.coordinates {
|
||||
let matches = findNearbyStadiums(coordinates: coordinates, sport: sport)
|
||||
results.append((metadata: photo, matches: matches))
|
||||
} else {
|
||||
// No coordinates - empty matches
|
||||
results.append((metadata: photo, matches: []))
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
}
|
||||
@@ -194,6 +194,7 @@ actor StubDataProvider: DataProvider {
|
||||
latitude: json.latitude,
|
||||
longitude: json.longitude,
|
||||
capacity: json.capacity,
|
||||
sport: parseSport(json.sport),
|
||||
yearOpened: json.year_opened
|
||||
)
|
||||
}
|
||||
|
||||
@@ -265,13 +265,17 @@ final class SuggestedTripsGenerator {
|
||||
// Build richGames dictionary
|
||||
let richGames = buildRichGames(from: selectedGames, teams: teams, stadiums: stadiums)
|
||||
|
||||
// Compute sports from games actually in the trip (not all selectedGames)
|
||||
let gameIdsInTrip = Set(trip.stops.flatMap { $0.games })
|
||||
let actualSports = Set(gameIdsInTrip.compactMap { richGames[$0]?.game.sport })
|
||||
|
||||
return SuggestedTrip(
|
||||
id: UUID(),
|
||||
region: region,
|
||||
isSingleSport: singleSport,
|
||||
isSingleSport: actualSports.count == 1,
|
||||
trip: trip,
|
||||
richGames: richGames,
|
||||
sports: sports
|
||||
sports: actualSports.isEmpty ? sports : actualSports
|
||||
)
|
||||
|
||||
case .failure:
|
||||
@@ -339,6 +343,10 @@ final class SuggestedTripsGenerator {
|
||||
|
||||
guard selectedGames.count >= 4 else { return nil }
|
||||
|
||||
// Ensure enough unique cities for a true cross-country trip
|
||||
let uniqueCities = Set(selectedGames.compactMap { stadiums[$0.stadiumId]?.city })
|
||||
guard uniqueCities.count >= 3 else { return nil }
|
||||
|
||||
// Calculate trip dates
|
||||
guard let firstGame = selectedGames.first,
|
||||
let lastGame = selectedGames.last else { return nil }
|
||||
@@ -377,13 +385,29 @@ final class SuggestedTripsGenerator {
|
||||
// Build richGames dictionary
|
||||
let richGames = buildRichGames(from: selectedGames, teams: teams, stadiums: stadiums)
|
||||
|
||||
// Validate the final trip meets cross-country requirements:
|
||||
// - At least 4 stops (cities)
|
||||
// - At least 2 different regions
|
||||
guard trip.stops.count >= 4 else { return nil }
|
||||
|
||||
let stopsWithRegions = trip.stops.compactMap { stop -> Region? in
|
||||
guard let stadium = stadiums.values.first(where: { $0.city == stop.city }) else { return nil }
|
||||
return stadium.region
|
||||
}
|
||||
let uniqueRegions = Set(stopsWithRegions)
|
||||
guard uniqueRegions.count >= 2 else { return nil }
|
||||
|
||||
// Compute sports from games actually in the trip (not all selectedGames)
|
||||
let gameIdsInTrip = Set(trip.stops.flatMap { $0.games })
|
||||
let actualSports = Set(gameIdsInTrip.compactMap { richGames[$0]?.game.sport })
|
||||
|
||||
return SuggestedTrip(
|
||||
id: UUID(),
|
||||
region: .crossCountry,
|
||||
isSingleSport: sports.count == 1,
|
||||
isSingleSport: actualSports.count == 1,
|
||||
trip: trip,
|
||||
richGames: richGames,
|
||||
sports: sports
|
||||
sports: actualSports.isEmpty ? sports : actualSports
|
||||
)
|
||||
|
||||
case .failure:
|
||||
|
||||
410
SportsTime/Core/Services/VisitPhotoService.swift
Normal file
410
SportsTime/Core/Services/VisitPhotoService.swift
Normal file
@@ -0,0 +1,410 @@
|
||||
//
|
||||
// VisitPhotoService.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Manages visit photos with CloudKit sync for backup.
|
||||
// Thumbnails stored locally in SwiftData for fast loading.
|
||||
// Full images stored in CloudKit private database.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
import SwiftData
|
||||
import UIKit
|
||||
|
||||
// MARK: - Photo Service Errors
|
||||
|
||||
enum PhotoServiceError: Error, LocalizedError {
|
||||
case notSignedIn
|
||||
case uploadFailed(String)
|
||||
case downloadFailed(String)
|
||||
case thumbnailGenerationFailed
|
||||
case invalidImage
|
||||
case assetNotFound
|
||||
case quotaExceeded
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notSignedIn:
|
||||
return "Please sign in to iCloud to sync photos"
|
||||
case .uploadFailed(let message):
|
||||
return "Upload failed: \(message)"
|
||||
case .downloadFailed(let message):
|
||||
return "Download failed: \(message)"
|
||||
case .thumbnailGenerationFailed:
|
||||
return "Could not generate thumbnail"
|
||||
case .invalidImage:
|
||||
return "Invalid image data"
|
||||
case .assetNotFound:
|
||||
return "Photo not found in cloud storage"
|
||||
case .quotaExceeded:
|
||||
return "iCloud storage quota exceeded"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Visit Photo Service
|
||||
|
||||
@MainActor
|
||||
final class VisitPhotoService {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
private let modelContext: ModelContext
|
||||
private let container: CKContainer
|
||||
private let privateDatabase: CKDatabase
|
||||
|
||||
// Configuration
|
||||
private static let thumbnailSize = CGSize(width: 200, height: 200)
|
||||
private static let compressionQuality: CGFloat = 0.7
|
||||
private static let recordType = "VisitPhoto"
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(modelContext: ModelContext) {
|
||||
self.modelContext = modelContext
|
||||
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
|
||||
self.privateDatabase = container.privateCloudDatabase
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Add a photo to a visit
|
||||
/// - Parameters:
|
||||
/// - visit: The visit to add the photo to
|
||||
/// - image: The UIImage to add
|
||||
/// - caption: Optional caption for the photo
|
||||
/// - Returns: The created photo metadata
|
||||
func addPhoto(to visit: StadiumVisit, image: UIImage, caption: String? = nil) async throws -> VisitPhotoMetadata {
|
||||
// Generate thumbnail
|
||||
guard let thumbnail = generateThumbnail(from: image) else {
|
||||
throw PhotoServiceError.thumbnailGenerationFailed
|
||||
}
|
||||
|
||||
guard let thumbnailData = thumbnail.jpegData(compressionQuality: Self.compressionQuality) else {
|
||||
throw PhotoServiceError.thumbnailGenerationFailed
|
||||
}
|
||||
|
||||
// Get current photo count for order index
|
||||
let orderIndex = visit.photoMetadata?.count ?? 0
|
||||
|
||||
// Create metadata record
|
||||
let metadata = VisitPhotoMetadata(
|
||||
visitId: visit.id,
|
||||
cloudKitAssetId: nil,
|
||||
thumbnailData: thumbnailData,
|
||||
caption: caption,
|
||||
orderIndex: orderIndex,
|
||||
uploadStatus: .pending
|
||||
)
|
||||
|
||||
// Add to visit
|
||||
if visit.photoMetadata == nil {
|
||||
visit.photoMetadata = []
|
||||
}
|
||||
visit.photoMetadata?.append(metadata)
|
||||
|
||||
modelContext.insert(metadata)
|
||||
try modelContext.save()
|
||||
|
||||
// Queue background upload
|
||||
Task.detached { [weak self] in
|
||||
await self?.uploadPhoto(metadata: metadata, image: image)
|
||||
}
|
||||
|
||||
return metadata
|
||||
}
|
||||
|
||||
/// Fetch full-resolution image for a photo
|
||||
/// - Parameter metadata: The photo metadata
|
||||
/// - Returns: The full-resolution UIImage
|
||||
func fetchFullImage(for metadata: VisitPhotoMetadata) async throws -> UIImage {
|
||||
guard let assetId = metadata.cloudKitAssetId else {
|
||||
throw PhotoServiceError.assetNotFound
|
||||
}
|
||||
|
||||
let recordID = CKRecord.ID(recordName: assetId)
|
||||
|
||||
do {
|
||||
let record = try await privateDatabase.record(for: recordID)
|
||||
|
||||
guard let asset = record["imageAsset"] as? CKAsset,
|
||||
let fileURL = asset.fileURL,
|
||||
let data = try? Data(contentsOf: fileURL),
|
||||
let image = UIImage(data: data) else {
|
||||
throw PhotoServiceError.downloadFailed("Could not read image data")
|
||||
}
|
||||
|
||||
return image
|
||||
} catch let error as CKError {
|
||||
throw mapCloudKitError(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a photo from visit and CloudKit
|
||||
/// - Parameter metadata: The photo metadata to delete
|
||||
func deletePhoto(_ metadata: VisitPhotoMetadata) async throws {
|
||||
// Delete from CloudKit if uploaded
|
||||
if let assetId = metadata.cloudKitAssetId {
|
||||
let recordID = CKRecord.ID(recordName: assetId)
|
||||
do {
|
||||
try await privateDatabase.deleteRecord(withID: recordID)
|
||||
} catch {
|
||||
// Continue with local deletion even if CloudKit fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from SwiftData
|
||||
modelContext.delete(metadata)
|
||||
try modelContext.save()
|
||||
}
|
||||
|
||||
/// Retry uploading failed photos
|
||||
func retryFailedUploads() async {
|
||||
let descriptor = FetchDescriptor<VisitPhotoMetadata>(
|
||||
predicate: #Predicate { $0.uploadStatusRaw == "failed" || $0.uploadStatusRaw == "pending" }
|
||||
)
|
||||
|
||||
do {
|
||||
let pendingPhotos = try modelContext.fetch(descriptor)
|
||||
|
||||
for metadata in pendingPhotos {
|
||||
// We can't upload without the original image
|
||||
// Mark as failed permanently if no thumbnail
|
||||
if metadata.thumbnailData == nil {
|
||||
metadata.uploadStatus = .failed
|
||||
}
|
||||
}
|
||||
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
// Silently fail - will retry on next launch
|
||||
}
|
||||
}
|
||||
|
||||
/// Get upload status summary
|
||||
func getUploadStatus() -> (pending: Int, uploaded: Int, failed: Int) {
|
||||
let descriptor = FetchDescriptor<VisitPhotoMetadata>()
|
||||
|
||||
do {
|
||||
let all = try modelContext.fetch(descriptor)
|
||||
|
||||
let pending = all.filter { $0.uploadStatus == .pending }.count
|
||||
let uploaded = all.filter { $0.uploadStatus == .uploaded }.count
|
||||
let failed = all.filter { $0.uploadStatus == .failed }.count
|
||||
|
||||
return (pending, uploaded, failed)
|
||||
} catch {
|
||||
return (0, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if CloudKit is available for photo sync
|
||||
func isCloudKitAvailable() async -> Bool {
|
||||
do {
|
||||
let status = try await container.accountStatus()
|
||||
return status == .available
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
private func uploadPhoto(metadata: VisitPhotoMetadata, image: UIImage) async {
|
||||
guard let imageData = image.jpegData(compressionQuality: Self.compressionQuality) else {
|
||||
await MainActor.run {
|
||||
metadata.uploadStatus = .failed
|
||||
try? modelContext.save()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check CloudKit availability
|
||||
do {
|
||||
let status = try await container.accountStatus()
|
||||
guard status == .available else {
|
||||
await MainActor.run {
|
||||
metadata.uploadStatus = .failed
|
||||
try? modelContext.save()
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
metadata.uploadStatus = .failed
|
||||
try? modelContext.save()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Create CloudKit record
|
||||
let recordID = CKRecord.ID(recordName: metadata.id.uuidString)
|
||||
let record = CKRecord(recordType: Self.recordType, recordID: recordID)
|
||||
|
||||
// Write image to temporary file for CKAsset
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
.appendingPathExtension("jpg")
|
||||
|
||||
do {
|
||||
try imageData.write(to: tempURL)
|
||||
|
||||
let asset = CKAsset(fileURL: tempURL)
|
||||
record["imageAsset"] = asset
|
||||
record["visitId"] = metadata.visitId.uuidString
|
||||
record["caption"] = metadata.caption
|
||||
record["orderIndex"] = metadata.orderIndex as CKRecordValue
|
||||
|
||||
// Upload to CloudKit
|
||||
let savedRecord = try await privateDatabase.save(record)
|
||||
|
||||
// Clean up temp file
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
|
||||
// Update metadata
|
||||
await MainActor.run {
|
||||
metadata.cloudKitAssetId = savedRecord.recordID.recordName
|
||||
metadata.uploadStatus = .uploaded
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
} catch let error as CKError {
|
||||
// Clean up temp file
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
|
||||
await MainActor.run {
|
||||
metadata.uploadStatus = .failed
|
||||
try? modelContext.save()
|
||||
}
|
||||
} catch {
|
||||
// Clean up temp file
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
|
||||
await MainActor.run {
|
||||
metadata.uploadStatus = .failed
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func generateThumbnail(from image: UIImage) -> UIImage? {
|
||||
let size = Self.thumbnailSize
|
||||
let aspectRatio = image.size.width / image.size.height
|
||||
|
||||
let targetSize: CGSize
|
||||
if aspectRatio > 1 {
|
||||
// Landscape
|
||||
targetSize = CGSize(width: size.width, height: size.width / aspectRatio)
|
||||
} else {
|
||||
// Portrait or square
|
||||
targetSize = CGSize(width: size.height * aspectRatio, height: size.height)
|
||||
}
|
||||
|
||||
let renderer = UIGraphicsImageRenderer(size: targetSize)
|
||||
return renderer.image { context in
|
||||
image.draw(in: CGRect(origin: .zero, size: targetSize))
|
||||
}
|
||||
}
|
||||
|
||||
private func mapCloudKitError(_ error: CKError) -> PhotoServiceError {
|
||||
switch error.code {
|
||||
case .notAuthenticated:
|
||||
return .notSignedIn
|
||||
case .quotaExceeded:
|
||||
return .quotaExceeded
|
||||
case .unknownItem:
|
||||
return .assetNotFound
|
||||
default:
|
||||
return .downloadFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Photo Gallery View Model
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class PhotoGalleryViewModel {
|
||||
var photos: [VisitPhotoMetadata] = []
|
||||
var selectedPhoto: VisitPhotoMetadata?
|
||||
var fullResolutionImage: UIImage?
|
||||
var isLoadingFullImage = false
|
||||
var error: PhotoServiceError?
|
||||
|
||||
private let photoService: VisitPhotoService
|
||||
private let visit: StadiumVisit
|
||||
|
||||
init(visit: StadiumVisit, modelContext: ModelContext) {
|
||||
self.visit = visit
|
||||
self.photoService = VisitPhotoService(modelContext: modelContext)
|
||||
loadPhotos()
|
||||
}
|
||||
|
||||
func loadPhotos() {
|
||||
photos = (visit.photoMetadata ?? []).sorted { $0.orderIndex < $1.orderIndex }
|
||||
}
|
||||
|
||||
func addPhoto(_ image: UIImage, caption: String? = nil) async {
|
||||
do {
|
||||
let metadata = try await photoService.addPhoto(to: visit, image: image, caption: caption)
|
||||
photos.append(metadata)
|
||||
photos.sort { $0.orderIndex < $1.orderIndex }
|
||||
} catch let error as PhotoServiceError {
|
||||
self.error = error
|
||||
} catch {
|
||||
self.error = .uploadFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func selectPhoto(_ metadata: VisitPhotoMetadata) {
|
||||
selectedPhoto = metadata
|
||||
loadFullResolution(for: metadata)
|
||||
}
|
||||
|
||||
func loadFullResolution(for metadata: VisitPhotoMetadata) {
|
||||
guard metadata.cloudKitAssetId != nil else {
|
||||
// Photo not uploaded yet, use thumbnail
|
||||
if let data = metadata.thumbnailData {
|
||||
fullResolutionImage = UIImage(data: data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
isLoadingFullImage = true
|
||||
Task {
|
||||
do {
|
||||
let image = try await photoService.fetchFullImage(for: metadata)
|
||||
fullResolutionImage = image
|
||||
} catch let error as PhotoServiceError {
|
||||
self.error = error
|
||||
// Fall back to thumbnail
|
||||
if let data = metadata.thumbnailData {
|
||||
fullResolutionImage = UIImage(data: data)
|
||||
}
|
||||
} catch {
|
||||
self.error = .downloadFailed(error.localizedDescription)
|
||||
}
|
||||
isLoadingFullImage = false
|
||||
}
|
||||
}
|
||||
|
||||
func deletePhoto(_ metadata: VisitPhotoMetadata) async {
|
||||
do {
|
||||
try await photoService.deletePhoto(metadata)
|
||||
photos.removeAll { $0.id == metadata.id }
|
||||
if selectedPhoto?.id == metadata.id {
|
||||
selectedPhoto = nil
|
||||
fullResolutionImage = nil
|
||||
}
|
||||
} catch let error as PhotoServiceError {
|
||||
self.error = error
|
||||
} catch {
|
||||
self.error = .uploadFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func clearError() {
|
||||
error = nil
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,75 @@ struct AnimatedRouteGraphic: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Themed Spinner
|
||||
|
||||
/// A custom animated spinner matching the app's visual style
|
||||
struct ThemedSpinner: View {
|
||||
var size: CGFloat = 40
|
||||
var lineWidth: CGFloat = 4
|
||||
|
||||
@State private var rotation: Double = 0
|
||||
@State private var trimEnd: CGFloat = 0.6
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background track
|
||||
Circle()
|
||||
.stroke(Theme.warmOrange.opacity(0.15), lineWidth: lineWidth)
|
||||
|
||||
// Animated arc
|
||||
Circle()
|
||||
.trim(from: 0, to: trimEnd)
|
||||
.stroke(
|
||||
AngularGradient(
|
||||
gradient: Gradient(colors: [Theme.warmOrange, Theme.routeGold, Theme.warmOrange.opacity(0.3)]),
|
||||
center: .center,
|
||||
startAngle: .degrees(0),
|
||||
endAngle: .degrees(360)
|
||||
),
|
||||
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(rotation))
|
||||
|
||||
// Center glow dot
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.2))
|
||||
.frame(width: size * 0.3, height: size * 0.3)
|
||||
.blur(radius: 4)
|
||||
}
|
||||
.frame(width: size, height: size)
|
||||
.onAppear {
|
||||
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
|
||||
rotation = 360
|
||||
}
|
||||
withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) {
|
||||
trimEnd = 0.8
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Compact themed spinner for inline use
|
||||
struct ThemedSpinnerCompact: View {
|
||||
var size: CGFloat = 20
|
||||
var color: Color = Theme.warmOrange
|
||||
|
||||
@State private var rotation: Double = 0
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.trim(from: 0, to: 0.7)
|
||||
.stroke(color, style: StrokeStyle(lineWidth: size > 16 ? 2.5 : 2, lineCap: .round))
|
||||
.frame(width: size, height: size)
|
||||
.rotationEffect(.degrees(rotation))
|
||||
.onAppear {
|
||||
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) {
|
||||
rotation = 360
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pulsing Dot
|
||||
|
||||
struct PulsingDot: View {
|
||||
@@ -188,10 +257,8 @@ struct PlanningProgressView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
// Simple spinner
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
.tint(Theme.warmOrange)
|
||||
// Themed spinner
|
||||
ThemedSpinner(size: 56, lineWidth: 5)
|
||||
|
||||
// Current step text
|
||||
Text(steps[currentStep])
|
||||
@@ -284,8 +351,96 @@ struct EmptyStateView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Loading Overlay
|
||||
|
||||
/// A modal loading overlay with progress indication
|
||||
/// Reusable pattern from PDF export overlay
|
||||
struct LoadingOverlay: View {
|
||||
let message: String
|
||||
var detail: String?
|
||||
var progress: Double?
|
||||
var icon: String = "hourglass"
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background dimmer
|
||||
Color.black.opacity(0.6)
|
||||
.ignoresSafeArea()
|
||||
|
||||
// Progress card
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Progress ring or spinner
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Theme.cardBackgroundElevated(colorScheme), lineWidth: 8)
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
if let progress = progress {
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||
.frame(width: 80, height: 80)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.3), value: progress)
|
||||
} else {
|
||||
ThemedSpinner(size: 48, lineWidth: 5)
|
||||
}
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
.opacity(progress != nil ? 1 : 0)
|
||||
}
|
||||
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
Text(message)
|
||||
.font(.system(size: Theme.FontSize.cardTitle, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
if let detail = detail {
|
||||
Text(detail)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
if let progress = progress {
|
||||
Text("\(Int(progress * 100))%")
|
||||
.font(.system(size: Theme.FontSize.micro, weight: .medium, design: .monospaced))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.xl)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.shadow(color: .black.opacity(0.3), radius: 20, y: 10)
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview("Themed Spinners") {
|
||||
VStack(spacing: 40) {
|
||||
ThemedSpinner(size: 60, lineWidth: 5)
|
||||
|
||||
ThemedSpinner(size: 40)
|
||||
|
||||
ThemedSpinnerCompact()
|
||||
|
||||
HStack(spacing: 20) {
|
||||
ThemedSpinnerCompact(size: 16)
|
||||
Text("Loading...")
|
||||
}
|
||||
}
|
||||
.padding(40)
|
||||
.themedBackground()
|
||||
}
|
||||
|
||||
#Preview("Animated Components") {
|
||||
VStack(spacing: 40) {
|
||||
AnimatedRouteGraphic()
|
||||
@@ -306,3 +461,25 @@ struct EmptyStateView: View {
|
||||
.padding()
|
||||
.themedBackground()
|
||||
}
|
||||
|
||||
#Preview("Loading Overlay") {
|
||||
ZStack {
|
||||
Color.gray
|
||||
LoadingOverlay(
|
||||
message: "Planning Your Trip",
|
||||
detail: "Finding the best route..."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Loading Overlay with Progress") {
|
||||
ZStack {
|
||||
Color.gray
|
||||
LoadingOverlay(
|
||||
message: "Creating PDF",
|
||||
detail: "Processing images...",
|
||||
progress: 0.65,
|
||||
icon: "doc.fill"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
598
SportsTime/Export/Services/ProgressCardGenerator.swift
Normal file
598
SportsTime/Export/Services/ProgressCardGenerator.swift
Normal file
@@ -0,0 +1,598 @@
|
||||
//
|
||||
// ProgressCardGenerator.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Generates shareable progress cards for social media.
|
||||
// Cards include progress ring, stats, optional username, and app branding.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import MapKit
|
||||
|
||||
// MARK: - Progress Card Generator
|
||||
|
||||
@MainActor
|
||||
final class ProgressCardGenerator {
|
||||
|
||||
// Card dimensions (Instagram story size)
|
||||
private static let cardSize = CGSize(width: 1080, height: 1920)
|
||||
private static let mapSnapshotSize = CGSize(width: 1000, height: 500)
|
||||
|
||||
// MARK: - Generate Card
|
||||
|
||||
/// Generate a shareable progress card image
|
||||
/// - Parameters:
|
||||
/// - progress: The league progress data
|
||||
/// - options: Card generation options
|
||||
/// - Returns: The generated UIImage
|
||||
func generateCard(
|
||||
progress: LeagueProgress,
|
||||
options: ProgressCardOptions = ProgressCardOptions()
|
||||
) async throws -> UIImage {
|
||||
// Generate map snapshot if needed
|
||||
var mapSnapshot: UIImage?
|
||||
if options.includeMapSnapshot {
|
||||
mapSnapshot = await generateMapSnapshot(
|
||||
visited: progress.stadiumsVisited,
|
||||
remaining: progress.stadiumsRemaining
|
||||
)
|
||||
}
|
||||
|
||||
// Render SwiftUI view to image
|
||||
let cardView = ProgressCardView(
|
||||
progress: progress,
|
||||
options: options,
|
||||
mapSnapshot: mapSnapshot
|
||||
)
|
||||
|
||||
let renderer = ImageRenderer(content: cardView)
|
||||
renderer.scale = 3.0 // High resolution
|
||||
|
||||
guard let image = renderer.uiImage else {
|
||||
throw CardGeneratorError.renderingFailed
|
||||
}
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
/// Generate a map snapshot showing visited/unvisited stadiums
|
||||
/// - Parameters:
|
||||
/// - visited: Stadiums that have been visited
|
||||
/// - remaining: Stadiums not yet visited
|
||||
/// - Returns: The map snapshot image
|
||||
func generateMapSnapshot(
|
||||
visited: [Stadium],
|
||||
remaining: [Stadium]
|
||||
) async -> UIImage? {
|
||||
let allStadiums = visited + remaining
|
||||
guard !allStadiums.isEmpty else { return nil }
|
||||
|
||||
// Calculate region to show all stadiums
|
||||
let coordinates = allStadiums.map {
|
||||
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
|
||||
}
|
||||
|
||||
let minLat = coordinates.map(\.latitude).min() ?? 0
|
||||
let maxLat = coordinates.map(\.latitude).max() ?? 0
|
||||
let minLon = coordinates.map(\.longitude).min() ?? 0
|
||||
let maxLon = coordinates.map(\.longitude).max() ?? 0
|
||||
|
||||
let center = CLLocationCoordinate2D(
|
||||
latitude: (minLat + maxLat) / 2,
|
||||
longitude: (minLon + maxLon) / 2
|
||||
)
|
||||
|
||||
let span = MKCoordinateSpan(
|
||||
latitudeDelta: (maxLat - minLat) * 1.3,
|
||||
longitudeDelta: (maxLon - minLon) * 1.3
|
||||
)
|
||||
|
||||
let region = MKCoordinateRegion(center: center, span: span)
|
||||
|
||||
// Create snapshot options
|
||||
let options = MKMapSnapshotter.Options()
|
||||
options.region = region
|
||||
options.size = Self.mapSnapshotSize
|
||||
options.mapType = .mutedStandard
|
||||
|
||||
let snapshotter = MKMapSnapshotter(options: options)
|
||||
|
||||
do {
|
||||
let snapshot = try await snapshotter.start()
|
||||
|
||||
// Draw annotations on snapshot
|
||||
let image = UIGraphicsImageRenderer(size: Self.mapSnapshotSize).image { context in
|
||||
snapshot.image.draw(at: .zero)
|
||||
|
||||
// Draw stadium markers
|
||||
for stadium in remaining {
|
||||
let point = snapshot.point(for: CLLocationCoordinate2D(
|
||||
latitude: stadium.latitude,
|
||||
longitude: stadium.longitude
|
||||
))
|
||||
drawMarker(at: point, color: .gray, context: context.cgContext)
|
||||
}
|
||||
|
||||
for stadium in visited {
|
||||
let point = snapshot.point(for: CLLocationCoordinate2D(
|
||||
latitude: stadium.latitude,
|
||||
longitude: stadium.longitude
|
||||
))
|
||||
drawMarker(at: point, color: UIColor(Theme.warmOrange), context: context.cgContext)
|
||||
}
|
||||
}
|
||||
|
||||
return image
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func drawMarker(at point: CGPoint, color: UIColor, context: CGContext) {
|
||||
let markerSize: CGFloat = 16
|
||||
|
||||
context.setFillColor(color.cgColor)
|
||||
context.fillEllipse(in: CGRect(
|
||||
x: point.x - markerSize / 2,
|
||||
y: point.y - markerSize / 2,
|
||||
width: markerSize,
|
||||
height: markerSize
|
||||
))
|
||||
|
||||
// White border
|
||||
context.setStrokeColor(UIColor.white.cgColor)
|
||||
context.setLineWidth(2)
|
||||
context.strokeEllipse(in: CGRect(
|
||||
x: point.x - markerSize / 2,
|
||||
y: point.y - markerSize / 2,
|
||||
width: markerSize,
|
||||
height: markerSize
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Card Generator Errors
|
||||
|
||||
enum CardGeneratorError: Error, LocalizedError {
|
||||
case renderingFailed
|
||||
case mapSnapshotFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .renderingFailed:
|
||||
return "Failed to render progress card"
|
||||
case .mapSnapshotFailed:
|
||||
return "Failed to generate map snapshot"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress Card View
|
||||
|
||||
struct ProgressCardView: View {
|
||||
let progress: LeagueProgress
|
||||
let options: ProgressCardOptions
|
||||
let mapSnapshot: UIImage?
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// Background gradient
|
||||
LinearGradient(
|
||||
colors: options.cardStyle == .dark
|
||||
? [Color(hex: "1A1A2E"), Color(hex: "16213E")]
|
||||
: [Color.white, Color(hex: "F5F5F5")],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
|
||||
VStack(spacing: 40) {
|
||||
// App logo and title
|
||||
headerSection
|
||||
|
||||
Spacer()
|
||||
|
||||
// Progress ring
|
||||
progressRingSection
|
||||
|
||||
// Stats row
|
||||
if options.includeStats {
|
||||
statsSection
|
||||
}
|
||||
|
||||
// Map snapshot
|
||||
if options.includeMapSnapshot, let snapshot = mapSnapshot {
|
||||
mapSection(image: snapshot)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Username if included
|
||||
if options.includeUsername, let username = options.username, !username.isEmpty {
|
||||
usernameSection(username)
|
||||
}
|
||||
|
||||
// App branding footer
|
||||
footerSection
|
||||
}
|
||||
.padding(60)
|
||||
}
|
||||
.frame(width: 1080, height: 1920)
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var headerSection: some View {
|
||||
VStack(spacing: 16) {
|
||||
// Sport icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(progress.sport.themeColor.opacity(0.2))
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: progress.sport.iconName)
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(progress.sport.themeColor)
|
||||
}
|
||||
|
||||
Text("\(progress.sport.displayName) Stadium Quest")
|
||||
.font(.system(size: 48, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(options.cardStyle.textColor)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress Ring
|
||||
|
||||
private var progressRingSection: some View {
|
||||
ZStack {
|
||||
// Background ring
|
||||
Circle()
|
||||
.stroke(Theme.warmOrange.opacity(0.2), lineWidth: 24)
|
||||
.frame(width: 320, height: 320)
|
||||
|
||||
// Progress ring
|
||||
Circle()
|
||||
.trim(from: 0, to: progress.completionPercentage / 100)
|
||||
.stroke(
|
||||
Theme.warmOrange,
|
||||
style: StrokeStyle(lineWidth: 24, lineCap: .round)
|
||||
)
|
||||
.frame(width: 320, height: 320)
|
||||
.rotationEffect(.degrees(-90))
|
||||
|
||||
// Center content
|
||||
VStack(spacing: 8) {
|
||||
Text("\(progress.visitedStadiums)")
|
||||
.font(.system(size: 96, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(options.cardStyle.textColor)
|
||||
|
||||
Text("of \(progress.totalStadiums)")
|
||||
.font(.system(size: 32, weight: .medium))
|
||||
.foregroundStyle(options.cardStyle.secondaryTextColor)
|
||||
|
||||
Text("Stadiums Visited")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(options.cardStyle.secondaryTextColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stats
|
||||
|
||||
private var statsSection: some View {
|
||||
HStack(spacing: 60) {
|
||||
statItem(value: "\(progress.visitedStadiums)", label: "Visited")
|
||||
statItem(value: "\(progress.totalStadiums - progress.visitedStadiums)", label: "Remaining")
|
||||
statItem(value: String(format: "%.0f%%", progress.completionPercentage), label: "Complete")
|
||||
}
|
||||
.padding(.vertical, 30)
|
||||
.padding(.horizontal, 40)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.fill(options.cardStyle == .dark
|
||||
? Color.white.opacity(0.05)
|
||||
: Color.black.opacity(0.05))
|
||||
)
|
||||
}
|
||||
|
||||
private func statItem(value: String, label: String) -> some View {
|
||||
VStack(spacing: 8) {
|
||||
Text(value)
|
||||
.font(.system(size: 36, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: 20))
|
||||
.foregroundStyle(options.cardStyle.secondaryTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Map
|
||||
|
||||
private func mapSection(image: UIImage) -> some View {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(maxWidth: 960)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 20)
|
||||
.stroke(Theme.warmOrange.opacity(0.3), lineWidth: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Username
|
||||
|
||||
private func usernameSection(_ username: String) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "person.circle.fill")
|
||||
.font(.system(size: 24))
|
||||
Text(username)
|
||||
.font(.system(size: 28, weight: .medium))
|
||||
}
|
||||
.foregroundStyle(options.cardStyle.secondaryTextColor)
|
||||
}
|
||||
|
||||
// MARK: - Footer
|
||||
|
||||
private var footerSection: some View {
|
||||
VStack(spacing: 12) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "sportscourt.fill")
|
||||
.font(.system(size: 20))
|
||||
Text("SportsTime")
|
||||
.font(.system(size: 24, weight: .semibold))
|
||||
}
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
|
||||
Text("Track your stadium adventures")
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(options.cardStyle.secondaryTextColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Progress Share View
|
||||
|
||||
struct ProgressShareView: View {
|
||||
let progress: LeagueProgress
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var generatedImage: UIImage?
|
||||
@State private var isGenerating = false
|
||||
@State private var showShareSheet = false
|
||||
@State private var error: String?
|
||||
|
||||
@State private var includeUsername = true
|
||||
@State private var username = ""
|
||||
@State private var includeMap = true
|
||||
@State private var cardStyle: ProgressCardOptions.CardStyle = .dark
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Preview card
|
||||
previewCard
|
||||
.padding(.horizontal)
|
||||
|
||||
// Options
|
||||
optionsSection
|
||||
|
||||
// Generate button
|
||||
generateButton
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle("Share Progress")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let image = generatedImage {
|
||||
ShareSheet(items: [image])
|
||||
}
|
||||
}
|
||||
.alert("Error", isPresented: .constant(error != nil)) {
|
||||
Button("OK") { error = nil }
|
||||
} message: {
|
||||
Text(error ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var previewCard: some View {
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
Text("Preview")
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
// Mini preview
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.fill(cardStyle == .dark
|
||||
? Color(hex: "1A1A2E")
|
||||
: Color.white)
|
||||
.aspectRatio(9/16, contentMode: .fit)
|
||||
.frame(maxHeight: 300)
|
||||
|
||||
VStack(spacing: 12) {
|
||||
// Sport badge
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: progress.sport.iconName)
|
||||
Text(progress.sport.displayName)
|
||||
}
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(progress.sport.themeColor)
|
||||
|
||||
// Progress ring
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Theme.warmOrange.opacity(0.2), lineWidth: 4)
|
||||
.frame(width: 60, height: 60)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress.completionPercentage / 100)
|
||||
.stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 4, lineCap: .round))
|
||||
.frame(width: 60, height: 60)
|
||||
.rotationEffect(.degrees(-90))
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Text("\(progress.visitedStadiums)")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
Text("/\(progress.totalStadiums)")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
.foregroundStyle(cardStyle == .dark ? .white : .black)
|
||||
}
|
||||
|
||||
if includeMap {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.frame(height: 40)
|
||||
.overlay {
|
||||
Image(systemName: "map")
|
||||
.foregroundStyle(Color.gray)
|
||||
}
|
||||
}
|
||||
|
||||
if includeUsername && !username.isEmpty {
|
||||
Text("@\(username)")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(cardStyle == .dark ? Color.gray : Color.gray)
|
||||
}
|
||||
|
||||
// Branding
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "sportscourt.fill")
|
||||
Text("SportsTime")
|
||||
}
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var optionsSection: some View {
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
// Style selector
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text("Style")
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
styleButton(style: .dark, label: "Dark")
|
||||
styleButton(style: .light, label: "Light")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
// Username toggle
|
||||
Toggle(isOn: $includeUsername) {
|
||||
Text("Include Username")
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
if includeUsername {
|
||||
TextField("Username", text: $username)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
// Map toggle
|
||||
Toggle(isOn: $includeMap) {
|
||||
Text("Include Map")
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
.padding(.vertical)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
private func styleButton(style: ProgressCardOptions.CardStyle, label: String) -> some View {
|
||||
Button {
|
||||
withAnimation { cardStyle = style }
|
||||
} label: {
|
||||
Text(label)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(cardStyle == style ? .white : Theme.textPrimary(colorScheme))
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.background(cardStyle == style ? Theme.warmOrange : Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
private var generateButton: some View {
|
||||
Button {
|
||||
generateCard()
|
||||
} label: {
|
||||
HStack {
|
||||
if isGenerating {
|
||||
ThemedSpinnerCompact(size: 18, color: .white)
|
||||
} else {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
Text(isGenerating ? "Generating..." : "Generate & Share")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.warmOrange)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
}
|
||||
.disabled(isGenerating)
|
||||
}
|
||||
|
||||
private func generateCard() {
|
||||
isGenerating = true
|
||||
|
||||
Task {
|
||||
let options = ProgressCardOptions(
|
||||
includeUsername: includeUsername,
|
||||
username: username,
|
||||
includeMapSnapshot: includeMap,
|
||||
includeStats: true,
|
||||
cardStyle: cardStyle
|
||||
)
|
||||
|
||||
let generator = ProgressCardGenerator()
|
||||
|
||||
do {
|
||||
generatedImage = try await generator.generateCard(
|
||||
progress: progress,
|
||||
options: options
|
||||
)
|
||||
showShareSheet = true
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
}
|
||||
|
||||
isGenerating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
ProgressShareView(progress: LeagueProgress(
|
||||
sport: .mlb,
|
||||
totalStadiums: 30,
|
||||
visitedStadiums: 12,
|
||||
stadiumsVisited: [],
|
||||
stadiumsRemaining: []
|
||||
))
|
||||
}
|
||||
@@ -16,6 +16,7 @@ struct HomeView: View {
|
||||
@State private var selectedTab = 0
|
||||
@State private var suggestedTripsGenerator = SuggestedTripsGenerator()
|
||||
@State private var selectedSuggestedTrip: SuggestedTrip?
|
||||
@State private var tripCreationViewModel = TripCreationViewModel()
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $selectedTab) {
|
||||
@@ -83,6 +84,15 @@ struct HomeView: View {
|
||||
}
|
||||
.tag(2)
|
||||
|
||||
// Progress Tab
|
||||
NavigationStack {
|
||||
ProgressTabView()
|
||||
}
|
||||
.tabItem {
|
||||
Label("Progress", systemImage: "chart.bar.fill")
|
||||
}
|
||||
.tag(3)
|
||||
|
||||
// Settings Tab
|
||||
NavigationStack {
|
||||
SettingsView()
|
||||
@@ -90,11 +100,11 @@ struct HomeView: View {
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gear")
|
||||
}
|
||||
.tag(3)
|
||||
.tag(4)
|
||||
}
|
||||
.tint(Theme.warmOrange)
|
||||
.sheet(isPresented: $showNewTrip) {
|
||||
TripCreationView(initialSport: selectedSport)
|
||||
TripCreationView(viewModel: tripCreationViewModel, initialSport: selectedSport)
|
||||
}
|
||||
.onChange(of: showNewTrip) { _, isShowing in
|
||||
if !isShowing {
|
||||
@@ -110,6 +120,7 @@ struct HomeView: View {
|
||||
NavigationStack {
|
||||
TripDetailView(trip: suggestedTrip.trip, games: suggestedTrip.richGames)
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// PhotoImportViewModel.swift
|
||||
// SportsTime
|
||||
//
|
||||
// ViewModel for photo import flow - orchestrates extraction, matching, and import.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import PhotosUI
|
||||
import SwiftData
|
||||
import Photos
|
||||
|
||||
@MainActor @Observable
|
||||
final class PhotoImportViewModel {
|
||||
// State
|
||||
var showingPicker = false
|
||||
var isProcessing = false
|
||||
var processedCount = 0
|
||||
var totalCount = 0
|
||||
|
||||
// Results
|
||||
var processedPhotos: [PhotoImportCandidate] = []
|
||||
var confirmedImports: Set<UUID> = []
|
||||
var selectedMatches: [UUID: GameMatchCandidate] = [:]
|
||||
|
||||
// Services
|
||||
private let metadataExtractor = PhotoMetadataExtractor.shared
|
||||
private let gameMatcher = GameMatcher.shared
|
||||
|
||||
// MARK: - Computed
|
||||
|
||||
var categorized: GameMatcher.CategorizedImports {
|
||||
gameMatcher.categorizeImports(processedPhotos)
|
||||
}
|
||||
|
||||
var hasConfirmedImports: Bool {
|
||||
!confirmedImports.isEmpty
|
||||
}
|
||||
|
||||
var confirmedCount: Int {
|
||||
confirmedImports.count
|
||||
}
|
||||
|
||||
// MARK: - Photo Processing
|
||||
|
||||
func processSelectedPhotos(_ items: [PhotosPickerItem]) async {
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
isProcessing = true
|
||||
totalCount = items.count
|
||||
processedCount = 0
|
||||
processedPhotos = []
|
||||
confirmedImports = []
|
||||
selectedMatches = [:]
|
||||
|
||||
// Load PHAssets from PhotosPickerItems
|
||||
var assets: [PHAsset] = []
|
||||
|
||||
for item in items {
|
||||
if let assetId = item.itemIdentifier {
|
||||
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
|
||||
if let asset = fetchResult.firstObject {
|
||||
assets.append(asset)
|
||||
}
|
||||
}
|
||||
processedCount += 1
|
||||
}
|
||||
|
||||
// Extract metadata from all assets
|
||||
let metadataList = await metadataExtractor.extractMetadata(from: assets)
|
||||
|
||||
// Process each photo through game matcher
|
||||
processedCount = 0
|
||||
for metadata in metadataList {
|
||||
let candidate = await gameMatcher.processPhotoForImport(metadata: metadata)
|
||||
processedPhotos.append(candidate)
|
||||
|
||||
// Auto-confirm high-confidence matches
|
||||
if candidate.canAutoProcess {
|
||||
confirmedImports.insert(candidate.id)
|
||||
}
|
||||
|
||||
processedCount += 1
|
||||
}
|
||||
|
||||
isProcessing = false
|
||||
}
|
||||
|
||||
// MARK: - User Actions
|
||||
|
||||
func toggleConfirmation(for candidateId: UUID) {
|
||||
if confirmedImports.contains(candidateId) {
|
||||
confirmedImports.remove(candidateId)
|
||||
} else {
|
||||
confirmedImports.insert(candidateId)
|
||||
}
|
||||
}
|
||||
|
||||
func selectMatch(_ match: GameMatchCandidate, for candidateId: UUID) {
|
||||
selectedMatches[candidateId] = match
|
||||
confirmedImports.insert(candidateId)
|
||||
}
|
||||
|
||||
func confirmAll() {
|
||||
for candidate in processedPhotos {
|
||||
if case .singleMatch = candidate.matchResult {
|
||||
confirmedImports.insert(candidate.id)
|
||||
} else if case .multipleMatches = candidate.matchResult,
|
||||
selectedMatches[candidate.id] != nil {
|
||||
confirmedImports.insert(candidate.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import Creation
|
||||
|
||||
func createVisits(modelContext: ModelContext) async {
|
||||
for candidate in processedPhotos {
|
||||
guard confirmedImports.contains(candidate.id) else { continue }
|
||||
|
||||
// Get the match to use
|
||||
let matchToUse: GameMatchCandidate?
|
||||
|
||||
switch candidate.matchResult {
|
||||
case .singleMatch(let match):
|
||||
matchToUse = match
|
||||
case .multipleMatches:
|
||||
matchToUse = selectedMatches[candidate.id]
|
||||
case .noMatches:
|
||||
matchToUse = nil
|
||||
}
|
||||
|
||||
guard let match = matchToUse else { continue }
|
||||
|
||||
// Create the visit
|
||||
let visit = StadiumVisit(
|
||||
canonicalStadiumId: match.stadium.id.uuidString,
|
||||
stadiumUUID: match.stadium.id,
|
||||
stadiumNameAtVisit: match.stadium.name,
|
||||
visitDate: match.game.dateTime,
|
||||
sport: match.game.sport,
|
||||
visitType: .game,
|
||||
homeTeamName: match.homeTeam.fullName,
|
||||
awayTeamName: match.awayTeam.fullName,
|
||||
finalScore: nil,
|
||||
scoreSource: nil,
|
||||
dataSource: .automatic,
|
||||
seatLocation: nil,
|
||||
notes: nil,
|
||||
photoLatitude: candidate.metadata.coordinates?.latitude,
|
||||
photoLongitude: candidate.metadata.coordinates?.longitude,
|
||||
photoCaptureDate: candidate.metadata.captureDate,
|
||||
source: .photoImport
|
||||
)
|
||||
|
||||
modelContext.insert(visit)
|
||||
}
|
||||
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
// MARK: - Reset
|
||||
|
||||
func reset() {
|
||||
processedPhotos = []
|
||||
confirmedImports = []
|
||||
selectedMatches = [:]
|
||||
isProcessing = false
|
||||
processedCount = 0
|
||||
totalCount = 0
|
||||
}
|
||||
}
|
||||
204
SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift
Normal file
204
SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift
Normal file
@@ -0,0 +1,204 @@
|
||||
//
|
||||
// ProgressViewModel.swift
|
||||
// SportsTime
|
||||
//
|
||||
// ViewModel for stadium progress tracking and visualization.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ProgressViewModel {
|
||||
|
||||
// MARK: - State
|
||||
|
||||
var selectedSport: Sport = .mlb
|
||||
var isLoading = false
|
||||
var error: Error?
|
||||
var errorMessage: String?
|
||||
|
||||
// MARK: - Data
|
||||
|
||||
private(set) var visits: [StadiumVisit] = []
|
||||
private(set) var stadiums: [Stadium] = []
|
||||
private(set) var teams: [Team] = []
|
||||
|
||||
// MARK: - Dependencies
|
||||
|
||||
private var modelContainer: ModelContainer?
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
/// Overall progress for the selected sport
|
||||
var leagueProgress: LeagueProgress {
|
||||
// Filter stadiums by sport directly (same as sportStadiums)
|
||||
let sportStadiums = stadiums.filter { $0.sport == selectedSport }
|
||||
|
||||
let visitedStadiumIds = Set(
|
||||
visits
|
||||
.filter { $0.sportEnum == selectedSport }
|
||||
.compactMap { visit -> UUID? in
|
||||
// Match visit's canonical stadium ID to a stadium
|
||||
stadiums.first { stadium in
|
||||
stadium.id == visit.stadiumUUID
|
||||
}?.id
|
||||
}
|
||||
)
|
||||
|
||||
let visited = sportStadiums.filter { visitedStadiumIds.contains($0.id) }
|
||||
let remaining = sportStadiums.filter { !visitedStadiumIds.contains($0.id) }
|
||||
|
||||
return LeagueProgress(
|
||||
sport: selectedSport,
|
||||
totalStadiums: sportStadiums.count,
|
||||
visitedStadiums: visited.count,
|
||||
stadiumsVisited: visited,
|
||||
stadiumsRemaining: remaining
|
||||
)
|
||||
}
|
||||
|
||||
/// Stadium visit status indexed by stadium ID
|
||||
var stadiumVisitStatus: [UUID: StadiumVisitStatus] {
|
||||
var statusMap: [UUID: StadiumVisitStatus] = [:]
|
||||
|
||||
// Group visits by stadium
|
||||
let visitsByStadium = Dictionary(grouping: visits.filter { $0.sportEnum == selectedSport }) { $0.stadiumUUID }
|
||||
|
||||
for stadium in stadiums {
|
||||
if let stadiumVisits = visitsByStadium[stadium.id], !stadiumVisits.isEmpty {
|
||||
let summaries = stadiumVisits.map { visit in
|
||||
VisitSummary(
|
||||
id: visit.id,
|
||||
stadium: stadium,
|
||||
visitDate: visit.visitDate,
|
||||
visitType: visit.visitType,
|
||||
sport: selectedSport,
|
||||
matchup: visit.matchupDescription,
|
||||
score: visit.finalScore,
|
||||
photoCount: visit.photoMetadata?.count ?? 0,
|
||||
notes: visit.notes
|
||||
)
|
||||
}
|
||||
statusMap[stadium.id] = .visited(visits: summaries)
|
||||
} else {
|
||||
statusMap[stadium.id] = .notVisited
|
||||
}
|
||||
}
|
||||
|
||||
return statusMap
|
||||
}
|
||||
|
||||
/// Stadiums for the selected sport
|
||||
var sportStadiums: [Stadium] {
|
||||
stadiums.filter { $0.sport == selectedSport }
|
||||
}
|
||||
|
||||
/// Visited stadiums for the selected sport
|
||||
var visitedStadiums: [Stadium] {
|
||||
leagueProgress.stadiumsVisited
|
||||
}
|
||||
|
||||
/// Unvisited stadiums for the selected sport
|
||||
var unvisitedStadiums: [Stadium] {
|
||||
leagueProgress.stadiumsRemaining
|
||||
}
|
||||
|
||||
/// Recent visits sorted by date
|
||||
var recentVisits: [VisitSummary] {
|
||||
visits
|
||||
.sorted { $0.visitDate > $1.visitDate }
|
||||
.prefix(10)
|
||||
.compactMap { visit -> VisitSummary? in
|
||||
guard let stadium = stadiums.first(where: { $0.id == visit.stadiumUUID }),
|
||||
let sport = visit.sportEnum else {
|
||||
return nil
|
||||
}
|
||||
return VisitSummary(
|
||||
id: visit.id,
|
||||
stadium: stadium,
|
||||
visitDate: visit.visitDate,
|
||||
visitType: visit.visitType,
|
||||
sport: sport,
|
||||
matchup: visit.matchupDescription,
|
||||
score: visit.finalScore,
|
||||
photoCount: visit.photoMetadata?.count ?? 0,
|
||||
notes: visit.notes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
|
||||
func configure(with container: ModelContainer) {
|
||||
self.modelContainer = container
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func loadData() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
// Load stadiums and teams from data provider
|
||||
if dataProvider.stadiums.isEmpty {
|
||||
await dataProvider.loadInitialData()
|
||||
}
|
||||
stadiums = dataProvider.stadiums
|
||||
teams = dataProvider.teams
|
||||
|
||||
// Load visits from SwiftData
|
||||
if let container = modelContainer {
|
||||
let context = ModelContext(container)
|
||||
let descriptor = FetchDescriptor<StadiumVisit>(
|
||||
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
|
||||
)
|
||||
visits = try context.fetch(descriptor)
|
||||
}
|
||||
} catch {
|
||||
self.error = error
|
||||
self.errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func selectSport(_ sport: Sport) {
|
||||
selectedSport = sport
|
||||
}
|
||||
|
||||
func clearError() {
|
||||
error = nil
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
// MARK: - Visit Management
|
||||
|
||||
func deleteVisit(_ visit: StadiumVisit) async throws {
|
||||
guard let container = modelContainer else { return }
|
||||
|
||||
let context = ModelContext(container)
|
||||
context.delete(visit)
|
||||
try context.save()
|
||||
|
||||
// Reload data
|
||||
await loadData()
|
||||
}
|
||||
|
||||
// MARK: - Progress Card Generation
|
||||
|
||||
func progressCardData(includeUsername: Bool = false) -> ProgressCardData {
|
||||
ProgressCardData(
|
||||
sport: selectedSport,
|
||||
progress: leagueProgress,
|
||||
username: nil,
|
||||
includeMap: true,
|
||||
showDetailedStats: false
|
||||
)
|
||||
}
|
||||
}
|
||||
526
SportsTime/Features/Progress/Views/AchievementsListView.swift
Normal file
526
SportsTime/Features/Progress/Views/AchievementsListView.swift
Normal file
@@ -0,0 +1,526 @@
|
||||
//
|
||||
// AchievementsListView.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Displays achievements gallery with earned, in-progress, and locked badges.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct AchievementsListView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@State private var achievements: [AchievementProgress] = []
|
||||
@State private var isLoading = true
|
||||
@State private var selectedCategory: AchievementCategory?
|
||||
@State private var selectedAchievement: AchievementProgress?
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Summary header
|
||||
achievementSummary
|
||||
.staggeredAnimation(index: 0)
|
||||
|
||||
// Category filter
|
||||
categoryFilter
|
||||
.staggeredAnimation(index: 1)
|
||||
|
||||
// Achievements grid
|
||||
achievementsGrid
|
||||
.staggeredAnimation(index: 2)
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
.themedBackground()
|
||||
.navigationTitle("Achievements")
|
||||
.task {
|
||||
await loadAchievements()
|
||||
}
|
||||
.sheet(item: $selectedAchievement) { achievement in
|
||||
AchievementDetailSheet(achievement: achievement)
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievement Summary
|
||||
|
||||
private var achievementSummary: some View {
|
||||
let earned = achievements.filter { $0.isEarned }.count
|
||||
let total = achievements.count
|
||||
|
||||
return HStack(spacing: Theme.Spacing.lg) {
|
||||
// Trophy icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 70, height: 70)
|
||||
|
||||
Image(systemName: "trophy.fill")
|
||||
.font(.system(size: 32))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text("\(earned) / \(total)")
|
||||
.font(.system(size: Theme.FontSize.heroTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text("Achievements Earned")
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
if earned == total && total > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "star.fill")
|
||||
Text("All achievements unlocked!")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: Theme.cardShadow(colorScheme), radius: 10, y: 5)
|
||||
}
|
||||
|
||||
// MARK: - Category Filter
|
||||
|
||||
private var categoryFilter: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
CategoryFilterButton(
|
||||
title: "All",
|
||||
icon: "square.grid.2x2",
|
||||
isSelected: selectedCategory == nil
|
||||
) {
|
||||
withAnimation(Theme.Animation.spring) {
|
||||
selectedCategory = nil
|
||||
}
|
||||
}
|
||||
|
||||
ForEach(AchievementCategory.allCases, id: \.self) { category in
|
||||
CategoryFilterButton(
|
||||
title: category.displayName,
|
||||
icon: category.iconName,
|
||||
isSelected: selectedCategory == category
|
||||
) {
|
||||
withAnimation(Theme.Animation.spring) {
|
||||
selectedCategory = category
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievements Grid
|
||||
|
||||
private var achievementsGrid: some View {
|
||||
let filtered = filteredAchievements
|
||||
|
||||
return LazyVGrid(
|
||||
columns: [GridItem(.flexible()), GridItem(.flexible())],
|
||||
spacing: Theme.Spacing.md
|
||||
) {
|
||||
ForEach(filtered) { achievement in
|
||||
AchievementCard(achievement: achievement)
|
||||
.onTapGesture {
|
||||
selectedAchievement = achievement
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filteredAchievements: [AchievementProgress] {
|
||||
guard let category = selectedCategory else {
|
||||
return achievements.sorted { first, second in
|
||||
// Earned first, then by progress
|
||||
if first.isEarned != second.isEarned {
|
||||
return first.isEarned
|
||||
}
|
||||
return first.progressPercentage > second.progressPercentage
|
||||
}
|
||||
}
|
||||
return achievements.filter { $0.definition.category == category }
|
||||
.sorted { first, second in
|
||||
if first.isEarned != second.isEarned {
|
||||
return first.isEarned
|
||||
}
|
||||
return first.progressPercentage > second.progressPercentage
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
private func loadAchievements() async {
|
||||
isLoading = true
|
||||
do {
|
||||
let engine = AchievementEngine(modelContext: modelContext)
|
||||
achievements = try await engine.getProgress()
|
||||
} catch {
|
||||
// Handle error silently, show empty state
|
||||
achievements = []
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Filter Button
|
||||
|
||||
struct CategoryFilterButton: View {
|
||||
let title: String
|
||||
let icon: String
|
||||
let isSelected: Bool
|
||||
let action: () -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14))
|
||||
Text(title)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.background(isSelected ? Theme.warmOrange : Theme.cardBackground(colorScheme))
|
||||
.foregroundStyle(isSelected ? .white : Theme.textPrimary(colorScheme))
|
||||
.clipShape(Capsule())
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(isSelected ? Color.clear : Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievement Card
|
||||
|
||||
struct AchievementCard: View {
|
||||
let achievement: AchievementProgress
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
// Badge icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(badgeBackgroundColor)
|
||||
.frame(width: 60, height: 60)
|
||||
|
||||
Image(systemName: achievement.definition.iconName)
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(badgeIconColor)
|
||||
|
||||
if !achievement.isEarned {
|
||||
Circle()
|
||||
.fill(.black.opacity(0.3))
|
||||
.frame(width: 60, height: 60)
|
||||
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
// Title
|
||||
Text(achievement.definition.name)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(achievement.isEarned ? Theme.textPrimary(colorScheme) : Theme.textMuted(colorScheme))
|
||||
.multilineTextAlignment(.center)
|
||||
.lineLimit(2)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
// Progress or earned date
|
||||
if achievement.isEarned {
|
||||
if let earnedAt = achievement.earnedAt {
|
||||
Text(earnedAt.formatted(date: .abbreviated, time: .omitted))
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
} else {
|
||||
// Progress bar
|
||||
VStack(spacing: 4) {
|
||||
ProgressView(value: achievement.progressPercentage)
|
||||
.progressViewStyle(AchievementProgressStyle())
|
||||
|
||||
Text(achievement.progressText)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.frame(maxWidth: .infinity, minHeight: 170)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(achievement.isEarned ? Theme.warmOrange.opacity(0.5) : Theme.surfaceGlow(colorScheme), lineWidth: achievement.isEarned ? 2 : 1)
|
||||
}
|
||||
.shadow(color: Theme.cardShadow(colorScheme), radius: 5, y: 2)
|
||||
.opacity(achievement.isEarned ? 1.0 : 0.7)
|
||||
}
|
||||
|
||||
private var badgeBackgroundColor: Color {
|
||||
if achievement.isEarned {
|
||||
return categoryColor.opacity(0.2)
|
||||
}
|
||||
return Theme.cardBackgroundElevated(colorScheme)
|
||||
}
|
||||
|
||||
private var badgeIconColor: Color {
|
||||
if achievement.isEarned {
|
||||
return categoryColor
|
||||
}
|
||||
return Theme.textMuted(colorScheme)
|
||||
}
|
||||
|
||||
private var categoryColor: Color {
|
||||
switch achievement.definition.category {
|
||||
case .count:
|
||||
return Theme.warmOrange
|
||||
case .division:
|
||||
return Theme.routeGold
|
||||
case .conference:
|
||||
return Theme.routeAmber
|
||||
case .league:
|
||||
return Color(hex: "FFD700") // Gold
|
||||
case .journey:
|
||||
return Color(hex: "9B59B6") // Purple
|
||||
case .special:
|
||||
return Color(hex: "E74C3C") // Red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievement Progress Style
|
||||
|
||||
struct AchievementProgressStyle: ProgressViewStyle {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Theme.cardBackgroundElevated(colorScheme))
|
||||
.frame(height: 4)
|
||||
|
||||
RoundedRectangle(cornerRadius: 2)
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 4)
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievement Detail Sheet
|
||||
|
||||
struct AchievementDetailSheet: View {
|
||||
let achievement: AchievementProgress
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: Theme.Spacing.xl) {
|
||||
// Large badge
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(badgeBackgroundColor)
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
if achievement.isEarned {
|
||||
Circle()
|
||||
.stroke(Theme.warmOrange, lineWidth: 4)
|
||||
.frame(width: 130, height: 130)
|
||||
}
|
||||
|
||||
Image(systemName: achievement.definition.iconName)
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(badgeIconColor)
|
||||
|
||||
if !achievement.isEarned {
|
||||
Circle()
|
||||
.fill(.black.opacity(0.3))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
|
||||
// Title and description
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
Text(achievement.definition.name)
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(achievement.definition.description)
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
// Category badge
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: achievement.definition.category.iconName)
|
||||
Text(achievement.definition.category.displayName)
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(categoryColor)
|
||||
.padding(.horizontal, Theme.Spacing.sm)
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
.background(categoryColor.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
// Status section
|
||||
if achievement.isEarned {
|
||||
if let earnedAt = achievement.earnedAt {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.green)
|
||||
|
||||
Text("Earned on \(earnedAt.formatted(date: .long, time: .omitted))")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Progress section
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
Text("Progress")
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
ProgressView(value: achievement.progressPercentage)
|
||||
.progressViewStyle(LargeProgressStyle())
|
||||
.frame(width: 200)
|
||||
|
||||
Text("\(achievement.currentProgress) / \(achievement.totalRequired)")
|
||||
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
// Sport badge if applicable
|
||||
if let sport = achievement.definition.sport {
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
Image(systemName: sport.iconName)
|
||||
Text(sport.displayName)
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(sport.themeColor)
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.background(sport.themeColor.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(Theme.Spacing.lg)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var badgeBackgroundColor: Color {
|
||||
if achievement.isEarned {
|
||||
return categoryColor.opacity(0.2)
|
||||
}
|
||||
return Theme.cardBackgroundElevated(colorScheme)
|
||||
}
|
||||
|
||||
private var badgeIconColor: Color {
|
||||
if achievement.isEarned {
|
||||
return categoryColor
|
||||
}
|
||||
return Theme.textMuted(colorScheme)
|
||||
}
|
||||
|
||||
private var categoryColor: Color {
|
||||
switch achievement.definition.category {
|
||||
case .count:
|
||||
return Theme.warmOrange
|
||||
case .division:
|
||||
return Theme.routeGold
|
||||
case .conference:
|
||||
return Theme.routeAmber
|
||||
case .league:
|
||||
return Color(hex: "FFD700")
|
||||
case .journey:
|
||||
return Color(hex: "9B59B6")
|
||||
case .special:
|
||||
return Color(hex: "E74C3C")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Large Progress Style
|
||||
|
||||
struct LargeProgressStyle: ProgressViewStyle {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Theme.cardBackgroundElevated(colorScheme))
|
||||
.frame(height: 8)
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 8)
|
||||
}
|
||||
}
|
||||
.frame(height: 8)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Category Extensions
|
||||
|
||||
extension AchievementCategory {
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .count: return "number.circle"
|
||||
case .division: return "map"
|
||||
case .conference: return "building.2"
|
||||
case .league: return "crown"
|
||||
case .journey: return "car.fill"
|
||||
case .special: return "star.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
AchievementsListView()
|
||||
}
|
||||
.modelContainer(for: StadiumVisit.self, inMemory: true)
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
//
|
||||
// GameMatchConfirmationView.swift
|
||||
// SportsTime
|
||||
//
|
||||
// View for confirming/selecting the correct game match from photo import.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - Game Match Confirmation View
|
||||
|
||||
struct GameMatchConfirmationView: View {
|
||||
let candidate: PhotoImportCandidate
|
||||
let onConfirm: (GameMatchCandidate) -> Void
|
||||
let onSkip: () -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var selectedMatch: GameMatchCandidate?
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Photo info header
|
||||
photoInfoHeader
|
||||
.staggeredAnimation(index: 0)
|
||||
|
||||
// Stadium info
|
||||
if let stadium = candidate.bestStadiumMatch {
|
||||
stadiumCard(stadium)
|
||||
.staggeredAnimation(index: 1)
|
||||
}
|
||||
|
||||
// Match options
|
||||
matchOptionsSection
|
||||
.staggeredAnimation(index: 2)
|
||||
|
||||
// Action buttons
|
||||
actionButtons
|
||||
.staggeredAnimation(index: 3)
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
.themedBackground()
|
||||
.navigationTitle("Confirm Game")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
// Pre-select if single match
|
||||
if case .singleMatch(let match) = candidate.matchResult {
|
||||
selectedMatch = match
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Photo Info Header
|
||||
|
||||
private var photoInfoHeader: some View {
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: "photo.fill")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
if let date = candidate.metadata.captureDate {
|
||||
Label(formatDate(date), systemImage: "calendar")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
if candidate.metadata.hasValidLocation {
|
||||
Label("Location data available", systemImage: "location.fill")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
} else {
|
||||
Label("No location data", systemImage: "location.slash")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
}
|
||||
|
||||
// MARK: - Stadium Card
|
||||
|
||||
private func stadiumCard(_ match: StadiumMatch) -> some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
HStack {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
Text("Nearest Stadium")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(match.stadium.name)
|
||||
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(match.stadium.fullAddress)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Distance badge
|
||||
VStack(spacing: 2) {
|
||||
Text(match.formattedDistance)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(confidenceColor(match.confidence))
|
||||
|
||||
Text(match.confidence.description)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Match Options Section
|
||||
|
||||
private var matchOptionsSection: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
HStack {
|
||||
Image(systemName: "sportscourt.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
Text(matchOptionsTitle)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Spacer()
|
||||
}
|
||||
|
||||
matchOptionsContent
|
||||
}
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
private var matchOptionsTitle: String {
|
||||
switch candidate.matchResult {
|
||||
case .singleMatch:
|
||||
return "Matched Game"
|
||||
case .multipleMatches(let matches):
|
||||
return "Select Game (\(matches.count) options)"
|
||||
case .noMatches:
|
||||
return "No Games Found"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var matchOptionsContent: some View {
|
||||
switch candidate.matchResult {
|
||||
case .singleMatch(let match):
|
||||
gameMatchRow(match, isSelected: true)
|
||||
|
||||
case .multipleMatches(let matches):
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(matches) { match in
|
||||
Button {
|
||||
selectedMatch = match
|
||||
} label: {
|
||||
gameMatchRow(match, isSelected: selectedMatch?.id == match.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .noMatches(let reason):
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text(reason.description)
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
private func gameMatchRow(_ match: GameMatchCandidate, isSelected: Bool) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(match.matchupDescription)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Image(systemName: match.game.sport.iconName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(match.game.sport.themeColor)
|
||||
}
|
||||
|
||||
Text(match.gameDateTime)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
// Confidence
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(combinedConfidenceColor(match.confidence.combined))
|
||||
.frame(width: 8, height: 8)
|
||||
Text(match.confidence.combined.description)
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Selection indicator
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(isSelected ? .green : Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(isSelected ? Theme.cardBackgroundElevated(colorScheme) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||||
.overlay {
|
||||
if isSelected {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(.green.opacity(0.5), lineWidth: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Action Buttons
|
||||
|
||||
private var actionButtons: some View {
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
// Confirm button
|
||||
Button {
|
||||
if let match = selectedMatch {
|
||||
onConfirm(match)
|
||||
dismiss()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
Text("Confirm & Import")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(selectedMatch != nil ? .green : Theme.textMuted(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
}
|
||||
.disabled(selectedMatch == nil)
|
||||
|
||||
// Skip button
|
||||
Button {
|
||||
onSkip()
|
||||
dismiss()
|
||||
} label: {
|
||||
Text("Skip This Photo")
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .long
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func confidenceColor(_ confidence: MatchConfidence) -> Color {
|
||||
switch confidence {
|
||||
case .high: return .green
|
||||
case .medium: return Theme.warmOrange
|
||||
case .low: return .red
|
||||
case .none: return Theme.textMuted(colorScheme)
|
||||
}
|
||||
}
|
||||
|
||||
private func combinedConfidenceColor(_ confidence: CombinedConfidence) -> Color {
|
||||
switch confidence {
|
||||
case .autoSelect: return .green
|
||||
case .userConfirm: return Theme.warmOrange
|
||||
case .manualOnly: return .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
let metadata = PhotoMetadata(
|
||||
captureDate: Date(),
|
||||
coordinates: nil
|
||||
)
|
||||
let candidate = PhotoImportCandidate(
|
||||
metadata: metadata,
|
||||
matchResult: .noMatches(.metadataMissing(.noLocation)),
|
||||
stadiumMatches: []
|
||||
)
|
||||
|
||||
GameMatchConfirmationView(
|
||||
candidate: candidate,
|
||||
onConfirm: { _ in },
|
||||
onSkip: {}
|
||||
)
|
||||
}
|
||||
548
SportsTime/Features/Progress/Views/PhotoImportView.swift
Normal file
548
SportsTime/Features/Progress/Views/PhotoImportView.swift
Normal file
@@ -0,0 +1,548 @@
|
||||
//
|
||||
// PhotoImportView.swift
|
||||
// SportsTime
|
||||
//
|
||||
// View for importing stadium visits from photos using GPS/date metadata.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
import PhotosUI
|
||||
import Photos
|
||||
|
||||
// MARK: - Photo Import View
|
||||
|
||||
struct PhotoImportView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var viewModel = PhotoImportViewModel()
|
||||
@State private var selectedPhotos: [PhotosPickerItem] = []
|
||||
@State private var showingPermissionAlert = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
if viewModel.isProcessing {
|
||||
processingView
|
||||
} else if viewModel.processedPhotos.isEmpty {
|
||||
emptyStateView
|
||||
} else {
|
||||
resultsView
|
||||
}
|
||||
}
|
||||
.themedBackground()
|
||||
.navigationTitle("Import from Photos")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
if !viewModel.processedPhotos.isEmpty {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Import") {
|
||||
importSelectedVisits()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
.disabled(!viewModel.hasConfirmedImports)
|
||||
}
|
||||
}
|
||||
}
|
||||
.photosPicker(
|
||||
isPresented: $viewModel.showingPicker,
|
||||
selection: $selectedPhotos,
|
||||
maxSelectionCount: 20,
|
||||
matching: .images,
|
||||
photoLibrary: .shared()
|
||||
)
|
||||
.onChange(of: selectedPhotos) { _, newValue in
|
||||
Task {
|
||||
await viewModel.processSelectedPhotos(newValue)
|
||||
}
|
||||
}
|
||||
.alert("Photo Library Access", isPresented: $showingPermissionAlert) {
|
||||
Button("Open Settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("SportsTime needs access to your photos to import stadium visits. Please enable access in Settings.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Empty State
|
||||
|
||||
private var emptyStateView: some View {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
Spacer()
|
||||
|
||||
// Icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 120, height: 120)
|
||||
|
||||
Image(systemName: "photo.on.rectangle.angled")
|
||||
.font(.system(size: 50))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
Text("Import from Photos")
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text("Select photos taken at stadiums to automatically log your visits. We'll use GPS and date data to match them to games.")
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, Theme.Spacing.xl)
|
||||
}
|
||||
|
||||
// Select Photos Button
|
||||
Button {
|
||||
checkPermissionsAndShowPicker()
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "photo.stack")
|
||||
Text("Select Photos")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.warmOrange)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.xl)
|
||||
|
||||
// Info card
|
||||
infoCard
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
|
||||
private var infoCard: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
HStack {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
Text("How it works")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
InfoRow(icon: "location.fill", text: "We read GPS location from your photos")
|
||||
InfoRow(icon: "calendar", text: "We match the date to scheduled games")
|
||||
InfoRow(icon: "checkmark.circle", text: "High confidence matches are auto-selected")
|
||||
InfoRow(icon: "hand.tap", text: "You confirm or edit the rest")
|
||||
}
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.padding(.horizontal, Theme.Spacing.lg)
|
||||
}
|
||||
|
||||
// MARK: - Processing View
|
||||
|
||||
private var processingView: some View {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
Spacer()
|
||||
|
||||
ThemedSpinner(size: 50, lineWidth: 4)
|
||||
|
||||
Text("Processing photos...")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
Text("\(viewModel.processedCount) of \(viewModel.totalCount) photos")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Results View
|
||||
|
||||
private var resultsView: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Summary header
|
||||
summaryHeader
|
||||
|
||||
// Categorized results
|
||||
if !viewModel.categorized.autoProcessable.isEmpty {
|
||||
resultSection(
|
||||
title: "Auto-Matched",
|
||||
subtitle: "High confidence matches",
|
||||
icon: "checkmark.circle.fill",
|
||||
color: .green,
|
||||
candidates: viewModel.categorized.autoProcessable
|
||||
)
|
||||
}
|
||||
|
||||
if !viewModel.categorized.needsConfirmation.isEmpty {
|
||||
resultSection(
|
||||
title: "Needs Confirmation",
|
||||
subtitle: "Please verify these matches",
|
||||
icon: "questionmark.circle.fill",
|
||||
color: Theme.warmOrange,
|
||||
candidates: viewModel.categorized.needsConfirmation
|
||||
)
|
||||
}
|
||||
|
||||
if !viewModel.categorized.needsManualEntry.isEmpty {
|
||||
resultSection(
|
||||
title: "Manual Entry Required",
|
||||
subtitle: "Could not auto-match these photos",
|
||||
icon: "exclamationmark.triangle.fill",
|
||||
color: .red,
|
||||
candidates: viewModel.categorized.needsManualEntry
|
||||
)
|
||||
}
|
||||
|
||||
// Add more photos button
|
||||
Button {
|
||||
viewModel.showingPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "plus.circle")
|
||||
Text("Add More Photos")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
.padding(.top, Theme.Spacing.md)
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
}
|
||||
|
||||
private var summaryHeader: some View {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
summaryBadge(
|
||||
count: viewModel.categorized.autoProcessable.count,
|
||||
label: "Auto",
|
||||
color: .green
|
||||
)
|
||||
|
||||
summaryBadge(
|
||||
count: viewModel.categorized.needsConfirmation.count,
|
||||
label: "Confirm",
|
||||
color: Theme.warmOrange
|
||||
)
|
||||
|
||||
summaryBadge(
|
||||
count: viewModel.categorized.needsManualEntry.count,
|
||||
label: "Manual",
|
||||
color: .red
|
||||
)
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
}
|
||||
|
||||
private func summaryBadge(count: Int, label: String, color: Color) -> some View {
|
||||
VStack(spacing: 4) {
|
||||
Text("\(count)")
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(color)
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private func resultSection(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
icon: String,
|
||||
color: Color,
|
||||
candidates: [PhotoImportCandidate]
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Section header
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(color)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text(subtitle)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Candidate cards
|
||||
ForEach(candidates) { candidate in
|
||||
PhotoImportCandidateCard(
|
||||
candidate: candidate,
|
||||
isConfirmed: viewModel.confirmedImports.contains(candidate.id),
|
||||
onToggleConfirm: {
|
||||
viewModel.toggleConfirmation(for: candidate.id)
|
||||
},
|
||||
onSelectMatch: { match in
|
||||
viewModel.selectMatch(match, for: candidate.id)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func checkPermissionsAndShowPicker() {
|
||||
Task {
|
||||
let status = await PhotoMetadataExtractor.shared.requestPhotoLibraryAccess()
|
||||
|
||||
await MainActor.run {
|
||||
switch status {
|
||||
case .authorized, .limited:
|
||||
viewModel.showingPicker = true
|
||||
case .denied, .restricted:
|
||||
showingPermissionAlert = true
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func importSelectedVisits() {
|
||||
Task {
|
||||
await viewModel.createVisits(modelContext: modelContext)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Photo Import Candidate Card
|
||||
|
||||
struct PhotoImportCandidateCard: View {
|
||||
let candidate: PhotoImportCandidate
|
||||
let isConfirmed: Bool
|
||||
let onToggleConfirm: () -> Void
|
||||
let onSelectMatch: (GameMatchCandidate) -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var showingMatchPicker = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Photo date/location info
|
||||
HStack {
|
||||
if let date = candidate.metadata.captureDate {
|
||||
Label(formatDate(date), systemImage: "calendar")
|
||||
}
|
||||
|
||||
if let stadium = candidate.bestStadiumMatch {
|
||||
Label(stadium.stadium.name, systemImage: "mappin")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Confirm toggle
|
||||
Button {
|
||||
onToggleConfirm()
|
||||
} label: {
|
||||
Image(systemName: isConfirmed ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title2)
|
||||
.foregroundStyle(isConfirmed ? .green : Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
// Match result
|
||||
matchResultView
|
||||
}
|
||||
.padding(Theme.Spacing.sm)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||||
.sheet(isPresented: $showingMatchPicker) {
|
||||
if case .multipleMatches(let matches) = candidate.matchResult {
|
||||
GameMatchPickerSheet(
|
||||
matches: matches,
|
||||
onSelect: { match in
|
||||
onSelectMatch(match)
|
||||
showingMatchPicker = false
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var matchResultView: some View {
|
||||
switch candidate.matchResult {
|
||||
case .singleMatch(let match):
|
||||
matchRow(match)
|
||||
|
||||
case .multipleMatches(let matches):
|
||||
Button {
|
||||
showingMatchPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(matches.count) possible games")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text("Tap to select the correct game")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
case .noMatches(let reason):
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundStyle(.red)
|
||||
Text(reason.description)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func matchRow(_ match: GameMatchCandidate) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(match.matchupDescription)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Image(systemName: match.game.sport.iconName)
|
||||
.foregroundStyle(match.game.sport.themeColor)
|
||||
}
|
||||
|
||||
Text("\(match.stadium.name) • \(match.gameDateTime)")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
// Confidence badge
|
||||
confidenceBadge(match.confidence.combined)
|
||||
}
|
||||
}
|
||||
|
||||
private func confidenceBadge(_ confidence: CombinedConfidence) -> some View {
|
||||
let (text, color): (String, Color) = {
|
||||
switch confidence {
|
||||
case .autoSelect:
|
||||
return ("High confidence", .green)
|
||||
case .userConfirm:
|
||||
return ("Needs confirmation", Theme.warmOrange)
|
||||
case .manualOnly:
|
||||
return ("Low confidence", .red)
|
||||
}
|
||||
}()
|
||||
|
||||
return Text(text)
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.foregroundStyle(color)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(color.opacity(0.15))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Game Match Picker Sheet
|
||||
|
||||
struct GameMatchPickerSheet: View {
|
||||
let matches: [GameMatchCandidate]
|
||||
let onSelect: (GameMatchCandidate) -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
List(matches) { match in
|
||||
Button {
|
||||
onSelect(match)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(match.fullMatchupDescription)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: match.game.sport.iconName)
|
||||
.foregroundStyle(match.game.sport.themeColor)
|
||||
}
|
||||
|
||||
Text("\(match.stadium.name) • \(match.gameDateTime)")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Select Game")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Info Row
|
||||
|
||||
private struct InfoRow: View {
|
||||
let icon: String
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 16)
|
||||
Text(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
PhotoImportView()
|
||||
.modelContainer(for: StadiumVisit.self, inMemory: true)
|
||||
}
|
||||
186
SportsTime/Features/Progress/Views/ProgressMapView.swift
Normal file
186
SportsTime/Features/Progress/Views/ProgressMapView.swift
Normal file
@@ -0,0 +1,186 @@
|
||||
//
|
||||
// ProgressMapView.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Interactive map showing stadium visit progress with custom annotations.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import MapKit
|
||||
|
||||
// MARK: - Progress Map View
|
||||
|
||||
struct ProgressMapView: View {
|
||||
let stadiums: [Stadium]
|
||||
let visitStatus: [UUID: StadiumVisitStatus]
|
||||
@Binding var selectedStadium: Stadium?
|
||||
|
||||
@State private var mapRegion = MKCoordinateRegion(
|
||||
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795), // US center
|
||||
span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 50)
|
||||
)
|
||||
|
||||
var body: some View {
|
||||
Map(coordinateRegion: $mapRegion, annotationItems: stadiums) { stadium in
|
||||
MapAnnotation(coordinate: CLLocationCoordinate2D(
|
||||
latitude: stadium.latitude,
|
||||
longitude: stadium.longitude
|
||||
)) {
|
||||
StadiumMapPin(
|
||||
stadium: stadium,
|
||||
isVisited: isVisited(stadium),
|
||||
isSelected: selectedStadium?.id == stadium.id,
|
||||
onTap: {
|
||||
withAnimation(.spring(response: 0.3)) {
|
||||
if selectedStadium?.id == stadium.id {
|
||||
selectedStadium = nil
|
||||
} else {
|
||||
selectedStadium = stadium
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
.mapStyle(.standard(elevation: .realistic))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 16))
|
||||
}
|
||||
|
||||
private func isVisited(_ stadium: Stadium) -> Bool {
|
||||
if case .visited = visitStatus[stadium.id] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stadium Map Pin
|
||||
|
||||
struct StadiumMapPin: View {
|
||||
let stadium: Stadium
|
||||
let isVisited: Bool
|
||||
let isSelected: Bool
|
||||
let onTap: () -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
VStack(spacing: 2) {
|
||||
ZStack {
|
||||
// Pin background
|
||||
Circle()
|
||||
.fill(pinColor)
|
||||
.frame(width: pinSize, height: pinSize)
|
||||
.shadow(color: .black.opacity(0.2), radius: 2, y: 1)
|
||||
|
||||
// Icon
|
||||
Image(systemName: isVisited ? "checkmark" : "sportscourt")
|
||||
.font(.system(size: iconSize, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
// Pin pointer
|
||||
Triangle()
|
||||
.fill(pinColor)
|
||||
.frame(width: 10, height: 6)
|
||||
.offset(y: -2)
|
||||
|
||||
// Stadium name (when selected)
|
||||
if isSelected {
|
||||
Text(stadium.name)
|
||||
.font(.caption2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundStyle(colorScheme == .dark ? .white : .primary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(colorScheme == .dark ? Color(.systemGray5) : .white)
|
||||
.shadow(color: .black.opacity(0.15), radius: 4, y: 2)
|
||||
}
|
||||
.fixedSize()
|
||||
.transition(.scale.combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.animation(.spring(response: 0.3), value: isSelected)
|
||||
}
|
||||
|
||||
private var pinColor: Color {
|
||||
if isVisited {
|
||||
return .green
|
||||
} else {
|
||||
return .orange
|
||||
}
|
||||
}
|
||||
|
||||
private var pinSize: CGFloat {
|
||||
isSelected ? 36 : 28
|
||||
}
|
||||
|
||||
private var iconSize: CGFloat {
|
||||
isSelected ? 16 : 12
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Triangle Shape
|
||||
|
||||
struct Triangle: Shape {
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
path.move(to: CGPoint(x: rect.midX, y: rect.maxY))
|
||||
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||||
path.closeSubpath()
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Map Region Extension
|
||||
|
||||
extension ProgressMapView {
|
||||
/// Calculate region to fit all stadiums
|
||||
static func region(for stadiums: [Stadium]) -> MKCoordinateRegion {
|
||||
guard !stadiums.isEmpty else {
|
||||
// Default to US center
|
||||
return MKCoordinateRegion(
|
||||
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795),
|
||||
span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 50)
|
||||
)
|
||||
}
|
||||
|
||||
let latitudes = stadiums.map { $0.latitude }
|
||||
let longitudes = stadiums.map { $0.longitude }
|
||||
|
||||
let minLat = latitudes.min()!
|
||||
let maxLat = latitudes.max()!
|
||||
let minLon = longitudes.min()!
|
||||
let maxLon = longitudes.max()!
|
||||
|
||||
let center = CLLocationCoordinate2D(
|
||||
latitude: (minLat + maxLat) / 2,
|
||||
longitude: (minLon + maxLon) / 2
|
||||
)
|
||||
|
||||
let span = MKCoordinateSpan(
|
||||
latitudeDelta: (maxLat - minLat) * 1.3 + 2, // Add padding
|
||||
longitudeDelta: (maxLon - minLon) * 1.3 + 2
|
||||
)
|
||||
|
||||
return MKCoordinateRegion(center: center, span: span)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
ProgressMapView(
|
||||
stadiums: [],
|
||||
visitStatus: [:],
|
||||
selectedStadium: .constant(nil)
|
||||
)
|
||||
.frame(height: 300)
|
||||
.padding()
|
||||
}
|
||||
685
SportsTime/Features/Progress/Views/ProgressTabView.swift
Normal file
685
SportsTime/Features/Progress/Views/ProgressTabView.swift
Normal file
@@ -0,0 +1,685 @@
|
||||
//
|
||||
// ProgressTabView.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Main view for stadium progress tracking with league selector and map.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct ProgressTabView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@State private var viewModel = ProgressViewModel()
|
||||
@State private var showVisitSheet = false
|
||||
@State private var showPhotoImport = false
|
||||
@State private var showShareSheet = false
|
||||
@State private var selectedStadium: Stadium?
|
||||
@State private var selectedVisitId: UUID?
|
||||
|
||||
@Query private var visits: [StadiumVisit]
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// League Selector
|
||||
leagueSelector
|
||||
.staggeredAnimation(index: 0)
|
||||
|
||||
// Progress Summary Card
|
||||
progressSummaryCard
|
||||
.staggeredAnimation(index: 1)
|
||||
|
||||
// Map View
|
||||
ProgressMapView(
|
||||
stadiums: viewModel.sportStadiums,
|
||||
visitStatus: viewModel.stadiumVisitStatus,
|
||||
selectedStadium: $selectedStadium
|
||||
)
|
||||
.frame(height: 300)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
.staggeredAnimation(index: 2)
|
||||
|
||||
// Stadium Lists
|
||||
stadiumListsSection
|
||||
.staggeredAnimation(index: 3)
|
||||
|
||||
// Achievements Teaser
|
||||
achievementsSection
|
||||
.staggeredAnimation(index: 4)
|
||||
|
||||
// Recent Visits
|
||||
if !viewModel.recentVisits.isEmpty {
|
||||
recentVisitsSection
|
||||
.staggeredAnimation(index: 5)
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
.themedBackground()
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button {
|
||||
showShareSheet = true
|
||||
} label: {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu {
|
||||
Button {
|
||||
showVisitSheet = true
|
||||
} label: {
|
||||
Label("Manual Entry", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button {
|
||||
showPhotoImport = true
|
||||
} label: {
|
||||
Label("Import from Photos", systemImage: "photo.on.rectangle.angled")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
viewModel.configure(with: modelContext.container)
|
||||
await viewModel.loadData()
|
||||
}
|
||||
.sheet(isPresented: $showVisitSheet) {
|
||||
StadiumVisitSheet(initialSport: viewModel.selectedSport) { _ in
|
||||
Task {
|
||||
await viewModel.loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showPhotoImport) {
|
||||
PhotoImportView()
|
||||
.onDisappear {
|
||||
Task {
|
||||
await viewModel.loadData()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $selectedStadium) { stadium in
|
||||
StadiumDetailSheet(
|
||||
stadium: stadium,
|
||||
visitStatus: viewModel.stadiumVisitStatus[stadium.id] ?? .notVisited,
|
||||
sport: viewModel.selectedSport,
|
||||
onVisitLogged: {
|
||||
Task {
|
||||
await viewModel.loadData()
|
||||
}
|
||||
}
|
||||
)
|
||||
.presentationDetents([.medium])
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
ProgressShareView(progress: viewModel.leagueProgress)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - League Selector
|
||||
|
||||
private var leagueSelector: some View {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(Sport.supported) { sport in
|
||||
LeagueSelectorButton(
|
||||
sport: sport,
|
||||
isSelected: viewModel.selectedSport == sport,
|
||||
progress: progressForSport(sport)
|
||||
) {
|
||||
withAnimation(Theme.Animation.spring) {
|
||||
viewModel.selectSport(sport)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func progressForSport(_ sport: Sport) -> Double {
|
||||
let visitedCount = viewModel.visits.filter { $0.sportEnum == sport }.count
|
||||
let total = LeagueStructure.stadiumCount(for: sport)
|
||||
guard total > 0 else { return 0 }
|
||||
return Double(min(visitedCount, total)) / Double(total)
|
||||
}
|
||||
|
||||
// MARK: - Progress Summary Card
|
||||
|
||||
private var progressSummaryCard: some View {
|
||||
let progress = viewModel.leagueProgress
|
||||
|
||||
return VStack(spacing: Theme.Spacing.lg) {
|
||||
// Title and progress ring
|
||||
HStack(alignment: .center, spacing: Theme.Spacing.lg) {
|
||||
// Progress Ring
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Theme.warmOrange.opacity(0.2), lineWidth: 8)
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress.progressFraction)
|
||||
.stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||
.frame(width: 80, height: 80)
|
||||
.rotationEffect(.degrees(-90))
|
||||
.animation(.easeInOut(duration: 0.5), value: progress.progressFraction)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
Text("\(progress.visitedStadiums)")
|
||||
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Text("/\(progress.totalStadiums)")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text(viewModel.selectedSport.displayName)
|
||||
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text("Stadium Quest")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
if progress.isComplete {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
Text("Complete!")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
} else {
|
||||
Text("\(progress.totalStadiums - progress.visitedStadiums) stadiums remaining")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Stats row
|
||||
HStack(spacing: Theme.Spacing.lg) {
|
||||
ProgressStatPill(
|
||||
icon: "mappin.circle.fill",
|
||||
value: "\(progress.visitedStadiums)",
|
||||
label: "Visited"
|
||||
)
|
||||
|
||||
ProgressStatPill(
|
||||
icon: "circle.dotted",
|
||||
value: "\(progress.totalStadiums - progress.visitedStadiums)",
|
||||
label: "Remaining"
|
||||
)
|
||||
|
||||
ProgressStatPill(
|
||||
icon: "percent",
|
||||
value: String(format: "%.0f%%", progress.completionPercentage),
|
||||
label: "Complete"
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
.shadow(color: Theme.cardShadow(colorScheme), radius: 10, y: 5)
|
||||
}
|
||||
|
||||
// MARK: - Stadium Lists Section
|
||||
|
||||
private var stadiumListsSection: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
// Visited Stadiums
|
||||
if !viewModel.visitedStadiums.isEmpty {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("Visited (\(viewModel.visitedStadiums.count))")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(viewModel.visitedStadiums) { stadium in
|
||||
StadiumChip(
|
||||
stadium: stadium,
|
||||
isVisited: true
|
||||
) {
|
||||
selectedStadium = stadium
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unvisited Stadiums
|
||||
if !viewModel.unvisitedStadiums.isEmpty {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
HStack {
|
||||
Image(systemName: "circle.dotted")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
Text("Not Yet Visited (\(viewModel.unvisitedStadiums.count))")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ForEach(viewModel.unvisitedStadiums) { stadium in
|
||||
StadiumChip(
|
||||
stadium: stadium,
|
||||
isVisited: false
|
||||
) {
|
||||
selectedStadium = stadium
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Achievements Section
|
||||
|
||||
private var achievementsSection: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
HStack {
|
||||
Text("Achievements")
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Spacer()
|
||||
|
||||
NavigationLink {
|
||||
AchievementsListView()
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Text("View All")
|
||||
Image(systemName: "chevron.right")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
|
||||
NavigationLink {
|
||||
AchievementsListView()
|
||||
} label: {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
// Trophy icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange.opacity(0.15))
|
||||
.frame(width: 50, height: 50)
|
||||
|
||||
Image(systemName: "trophy.fill")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Track Your Progress")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text("Earn badges for stadium visits")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(Theme.warmOrange.opacity(0.3), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Recent Visits Section
|
||||
|
||||
private var recentVisitsSection: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
Text("Recent Visits")
|
||||
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
ForEach(viewModel.recentVisits) { visitSummary in
|
||||
if let stadiumVisit = visits.first(where: { $0.id == visitSummary.id }) {
|
||||
NavigationLink {
|
||||
VisitDetailView(visit: stadiumVisit, stadium: visitSummary.stadium)
|
||||
} label: {
|
||||
RecentVisitRow(visit: visitSummary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
RecentVisitRow(visit: visitSummary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Supporting Views
|
||||
|
||||
struct LeagueSelectorButton: View {
|
||||
let sport: Sport
|
||||
let isSelected: Bool
|
||||
let progress: Double
|
||||
let action: () -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
ZStack {
|
||||
// Background circle with progress
|
||||
Circle()
|
||||
.stroke(sport.themeColor.opacity(0.2), lineWidth: 3)
|
||||
.frame(width: 50, height: 50)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: progress)
|
||||
.stroke(sport.themeColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
|
||||
.frame(width: 50, height: 50)
|
||||
.rotationEffect(.degrees(-90))
|
||||
|
||||
// Sport icon
|
||||
Image(systemName: sport.iconName)
|
||||
.font(.title2)
|
||||
.foregroundStyle(isSelected ? sport.themeColor : Theme.textMuted(colorScheme))
|
||||
}
|
||||
|
||||
Text(sport.rawValue)
|
||||
.font(.system(size: Theme.FontSize.micro, weight: isSelected ? .bold : .medium))
|
||||
.foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textMuted(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.background(isSelected ? Theme.cardBackground(colorScheme) : Color.clear)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
if isSelected {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(sport.themeColor, lineWidth: 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct ProgressStatPill: View {
|
||||
let icon: String
|
||||
let value: String
|
||||
let label: String
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 12))
|
||||
Text(value)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .bold))
|
||||
}
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(label)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct StadiumChip: View {
|
||||
let stadium: Stadium
|
||||
let isVisited: Bool
|
||||
let action: () -> Void
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: Theme.Spacing.xs) {
|
||||
if isVisited {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(stadium.name)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.lineLimit(1)
|
||||
|
||||
Text(stadium.city)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.sm)
|
||||
.padding(.vertical, Theme.Spacing.xs)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(Capsule())
|
||||
.overlay {
|
||||
Capsule()
|
||||
.stroke(isVisited ? Color.green.opacity(0.3) : Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
struct RecentVisitRow: View {
|
||||
let visit: VisitSummary
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: Theme.Spacing.md) {
|
||||
// Sport icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(visit.sport.themeColor.opacity(0.15))
|
||||
.frame(width: 40, height: 40)
|
||||
|
||||
Image(systemName: visit.sport.iconName)
|
||||
.foregroundStyle(visit.sport.themeColor)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(visit.stadium.name)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
Text(visit.shortDateDescription)
|
||||
if let matchup = visit.matchup {
|
||||
Text("•")
|
||||
Text(matchup)
|
||||
}
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if visit.photoCount > 0 {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "photo")
|
||||
Text("\(visit.photoCount)")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct StadiumDetailSheet: View {
|
||||
let stadium: Stadium
|
||||
let visitStatus: StadiumVisitStatus
|
||||
let sport: Sport
|
||||
var onVisitLogged: (() -> Void)?
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var showLogVisit = false
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Stadium header
|
||||
VStack(spacing: Theme.Spacing.sm) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(sport.themeColor.opacity(0.15))
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: visitStatus.isVisited ? "checkmark.seal.fill" : sport.iconName)
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(visitStatus.isVisited ? .green : sport.themeColor)
|
||||
}
|
||||
|
||||
Text(stadium.name)
|
||||
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(stadium.fullAddress)
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
if visitStatus.isVisited {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Text("Visited \(visitStatus.visitCount) time\(visitStatus.visitCount == 1 ? "" : "s")")
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
|
||||
// Visit history if visited
|
||||
if case .visited(let visits) = visitStatus {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
Text("Visit History")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
ForEach(visits.sorted(by: { $0.visitDate > $1.visitDate })) { visit in
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(visit.shortDateDescription)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
if let matchup = visit.matchup {
|
||||
Text(matchup)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Text(visit.visitType.displayName)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(Theme.Spacing.sm)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Action button
|
||||
Button {
|
||||
showLogVisit = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: visitStatus.isVisited ? "plus" : "checkmark.circle")
|
||||
Text(visitStatus.isVisited ? "Log Another Visit" : "Log Visit")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Theme.warmOrange)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
}
|
||||
.pressableStyle()
|
||||
}
|
||||
.padding(Theme.Spacing.lg)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showLogVisit) {
|
||||
StadiumVisitSheet(
|
||||
initialStadium: stadium,
|
||||
initialSport: sport
|
||||
) { _ in
|
||||
onVisitLogged?()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationStack {
|
||||
ProgressTabView()
|
||||
}
|
||||
.modelContainer(for: StadiumVisit.self, inMemory: true)
|
||||
}
|
||||
357
SportsTime/Features/Progress/Views/StadiumVisitSheet.swift
Normal file
357
SportsTime/Features/Progress/Views/StadiumVisitSheet.swift
Normal file
@@ -0,0 +1,357 @@
|
||||
//
|
||||
// StadiumVisitSheet.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Sheet for manually logging a stadium visit.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct StadiumVisitSheet: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
// Optional pre-selected values
|
||||
var initialStadium: Stadium?
|
||||
var initialSport: Sport?
|
||||
var onSave: ((StadiumVisit) -> Void)?
|
||||
|
||||
// Form state
|
||||
@State private var selectedSport: Sport
|
||||
@State private var selectedStadium: Stadium?
|
||||
@State private var visitDate: Date = Date()
|
||||
@State private var visitType: VisitType = .game
|
||||
@State private var homeTeamName: String = ""
|
||||
@State private var awayTeamName: String = ""
|
||||
@State private var homeScore: String = ""
|
||||
@State private var awayScore: String = ""
|
||||
@State private var seatLocation: String = ""
|
||||
@State private var notes: String = ""
|
||||
|
||||
// UI state
|
||||
@State private var showStadiumPicker = false
|
||||
@State private var isSaving = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
// Data
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
|
||||
init(
|
||||
initialStadium: Stadium? = nil,
|
||||
initialSport: Sport? = nil,
|
||||
onSave: ((StadiumVisit) -> Void)? = nil
|
||||
) {
|
||||
self.initialStadium = initialStadium
|
||||
self.initialSport = initialSport
|
||||
self.onSave = onSave
|
||||
_selectedSport = State(initialValue: initialSport ?? .mlb)
|
||||
_selectedStadium = State(initialValue: initialStadium)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Sport & Stadium Section
|
||||
Section {
|
||||
// Sport Picker
|
||||
Picker("Sport", selection: $selectedSport) {
|
||||
ForEach(Sport.supported) { sport in
|
||||
HStack {
|
||||
Image(systemName: sport.iconName)
|
||||
Text(sport.displayName)
|
||||
}
|
||||
.tag(sport)
|
||||
}
|
||||
}
|
||||
|
||||
// Stadium Selection
|
||||
Button {
|
||||
showStadiumPicker = true
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Stadium")
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
Spacer()
|
||||
if let stadium = selectedStadium {
|
||||
Text(stadium.name)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
} else {
|
||||
Text("Select Stadium")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Location")
|
||||
}
|
||||
|
||||
// Visit Details Section
|
||||
Section {
|
||||
DatePicker("Date", selection: $visitDate, displayedComponents: .date)
|
||||
|
||||
Picker("Visit Type", selection: $visitType) {
|
||||
ForEach(VisitType.allCases, id: \.self) { type in
|
||||
Text(type.displayName).tag(type)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text("Visit Details")
|
||||
}
|
||||
|
||||
// Game Info Section (only for game visits)
|
||||
if visitType == .game {
|
||||
Section {
|
||||
HStack {
|
||||
Text("Away Team")
|
||||
Spacer()
|
||||
TextField("Team Name", text: $awayTeamName)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Home Team")
|
||||
Spacer()
|
||||
TextField("Team Name", text: $homeTeamName)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text("Final Score")
|
||||
Spacer()
|
||||
TextField("Away", text: $awayScore)
|
||||
.keyboardType(.numberPad)
|
||||
.frame(width: 50)
|
||||
.multilineTextAlignment(.center)
|
||||
Text("-")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
TextField("Home", text: $homeScore)
|
||||
.keyboardType(.numberPad)
|
||||
.frame(width: 50)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
} header: {
|
||||
Text("Game Info")
|
||||
} footer: {
|
||||
Text("Leave blank if you don't remember the score")
|
||||
}
|
||||
}
|
||||
|
||||
// Optional Details Section
|
||||
Section {
|
||||
HStack {
|
||||
Text("Seat Location")
|
||||
Spacer()
|
||||
TextField("e.g., Section 120", text: $seatLocation)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text("Notes")
|
||||
TextEditor(text: $notes)
|
||||
.frame(minHeight: 80)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||||
}
|
||||
} header: {
|
||||
Text("Additional Info")
|
||||
}
|
||||
|
||||
// Error Message
|
||||
if let error = errorMessage {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(error)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.navigationTitle("Log Visit")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
saveVisit()
|
||||
}
|
||||
.disabled(!canSave || isSaving)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showStadiumPicker) {
|
||||
StadiumPickerSheet(
|
||||
sport: selectedSport,
|
||||
selectedStadium: $selectedStadium
|
||||
)
|
||||
}
|
||||
.onChange(of: selectedSport) { _, _ in
|
||||
// Clear stadium selection when sport changes
|
||||
if let stadium = selectedStadium {
|
||||
// Check if stadium belongs to new sport
|
||||
let sportTeams = dataProvider.teams.filter { $0.sport == selectedSport }
|
||||
if !sportTeams.contains(where: { $0.stadiumId == stadium.id }) {
|
||||
selectedStadium = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var canSave: Bool {
|
||||
selectedStadium != nil
|
||||
}
|
||||
|
||||
private var finalScoreString: String? {
|
||||
guard let away = Int(awayScore), let home = Int(homeScore) else {
|
||||
return nil
|
||||
}
|
||||
return "\(away)-\(home)"
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func saveVisit() {
|
||||
guard let stadium = selectedStadium else {
|
||||
errorMessage = "Please select a stadium"
|
||||
return
|
||||
}
|
||||
|
||||
isSaving = true
|
||||
errorMessage = nil
|
||||
|
||||
// Create the visit
|
||||
let visit = StadiumVisit(
|
||||
canonicalStadiumId: stadium.id.uuidString, // Simplified - in production use StadiumIdentityService
|
||||
stadiumUUID: stadium.id,
|
||||
stadiumNameAtVisit: stadium.name,
|
||||
visitDate: visitDate,
|
||||
sport: selectedSport,
|
||||
visitType: visitType,
|
||||
homeTeamName: homeTeamName.isEmpty ? nil : homeTeamName,
|
||||
awayTeamName: awayTeamName.isEmpty ? nil : awayTeamName,
|
||||
finalScore: finalScoreString,
|
||||
scoreSource: finalScoreString != nil ? .user : nil,
|
||||
dataSource: .fullyManual,
|
||||
seatLocation: seatLocation.isEmpty ? nil : seatLocation,
|
||||
notes: notes.isEmpty ? nil : notes,
|
||||
source: .manual
|
||||
)
|
||||
|
||||
// Save to SwiftData
|
||||
modelContext.insert(visit)
|
||||
|
||||
do {
|
||||
try modelContext.save()
|
||||
onSave?(visit)
|
||||
dismiss()
|
||||
} catch {
|
||||
errorMessage = "Failed to save visit: \(error.localizedDescription)"
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stadium Picker Sheet
|
||||
|
||||
struct StadiumPickerSheet: View {
|
||||
let sport: Sport
|
||||
@Binding var selectedStadium: Stadium?
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var searchText = ""
|
||||
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
|
||||
private var stadiums: [Stadium] {
|
||||
let sportTeams = dataProvider.teams.filter { $0.sport == sport }
|
||||
let stadiumIds = Set(sportTeams.map { $0.stadiumId })
|
||||
return dataProvider.stadiums.filter { stadiumIds.contains($0.id) }
|
||||
}
|
||||
|
||||
private var filteredStadiums: [Stadium] {
|
||||
if searchText.isEmpty {
|
||||
return stadiums.sorted { $0.name < $1.name }
|
||||
}
|
||||
return stadiums.filter {
|
||||
$0.name.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.city.localizedCaseInsensitiveContains(searchText)
|
||||
}.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if stadiums.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Stadiums",
|
||||
systemImage: "building.2",
|
||||
description: Text("No stadiums found for \(sport.displayName)")
|
||||
)
|
||||
} else if filteredStadiums.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
} else {
|
||||
List(filteredStadiums) { stadium in
|
||||
Button {
|
||||
selectedStadium = stadium
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(stadium.name)
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(stadium.fullAddress)
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if selectedStadium?.id == stadium.id {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
}
|
||||
}
|
||||
.searchable(text: $searchText, prompt: "Search stadiums")
|
||||
.navigationTitle("Select Stadium")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
StadiumVisitSheet()
|
||||
.modelContainer(for: StadiumVisit.self, inMemory: true)
|
||||
}
|
||||
538
SportsTime/Features/Progress/Views/VisitDetailView.swift
Normal file
538
SportsTime/Features/Progress/Views/VisitDetailView.swift
Normal file
@@ -0,0 +1,538 @@
|
||||
//
|
||||
// VisitDetailView.swift
|
||||
// SportsTime
|
||||
//
|
||||
// View for displaying and editing a stadium visit's details.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SwiftData
|
||||
|
||||
struct VisitDetailView: View {
|
||||
@Bindable var visit: StadiumVisit
|
||||
let stadium: Stadium
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
@State private var isEditing = false
|
||||
@State private var showDeleteConfirmation = false
|
||||
|
||||
// Edit state
|
||||
@State private var editVisitDate: Date
|
||||
@State private var editVisitType: VisitType
|
||||
@State private var editHomeTeamName: String
|
||||
@State private var editAwayTeamName: String
|
||||
@State private var editHomeScore: String
|
||||
@State private var editAwayScore: String
|
||||
@State private var editSeatLocation: String
|
||||
@State private var editNotes: String
|
||||
|
||||
init(visit: StadiumVisit, stadium: Stadium) {
|
||||
self.visit = visit
|
||||
self.stadium = stadium
|
||||
|
||||
// Initialize edit state from visit
|
||||
_editVisitDate = State(initialValue: visit.visitDate)
|
||||
_editVisitType = State(initialValue: visit.visitType)
|
||||
_editHomeTeamName = State(initialValue: visit.homeTeamName ?? "")
|
||||
_editAwayTeamName = State(initialValue: visit.awayTeamName ?? "")
|
||||
|
||||
// Parse score if available
|
||||
if let score = visit.finalScore {
|
||||
let parts = score.split(separator: "-")
|
||||
if parts.count == 2 {
|
||||
_editAwayScore = State(initialValue: String(parts[0]))
|
||||
_editHomeScore = State(initialValue: String(parts[1]))
|
||||
} else {
|
||||
_editAwayScore = State(initialValue: "")
|
||||
_editHomeScore = State(initialValue: "")
|
||||
}
|
||||
} else {
|
||||
_editAwayScore = State(initialValue: "")
|
||||
_editHomeScore = State(initialValue: "")
|
||||
}
|
||||
|
||||
_editSeatLocation = State(initialValue: visit.seatLocation ?? "")
|
||||
_editNotes = State(initialValue: visit.notes ?? "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Header
|
||||
visitHeader
|
||||
.staggeredAnimation(index: 0)
|
||||
|
||||
// Game info (if applicable)
|
||||
if visit.visitType == .game {
|
||||
gameInfoCard
|
||||
.staggeredAnimation(index: 1)
|
||||
}
|
||||
|
||||
// Visit details
|
||||
detailsCard
|
||||
.staggeredAnimation(index: 2)
|
||||
|
||||
// Notes
|
||||
if !isEditing && (visit.notes?.isEmpty == false) {
|
||||
notesCard
|
||||
.staggeredAnimation(index: 3)
|
||||
}
|
||||
|
||||
// Edit form (when editing)
|
||||
if isEditing {
|
||||
editForm
|
||||
.staggeredAnimation(index: 4)
|
||||
}
|
||||
|
||||
// Delete button
|
||||
if !isEditing {
|
||||
deleteButton
|
||||
.staggeredAnimation(index: 5)
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.md)
|
||||
}
|
||||
.themedBackground()
|
||||
.navigationTitle(isEditing ? "Edit Visit" : "Visit Details")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
if isEditing {
|
||||
Button("Save") {
|
||||
saveChanges()
|
||||
}
|
||||
.fontWeight(.semibold)
|
||||
} else {
|
||||
Button("Edit") {
|
||||
withAnimation {
|
||||
isEditing = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isEditing {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
cancelEditing()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Visit",
|
||||
isPresented: $showDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete Visit", role: .destructive) {
|
||||
deleteVisit()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete this visit? This action cannot be undone.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var visitHeader: some View {
|
||||
VStack(spacing: Theme.Spacing.md) {
|
||||
// Sport icon
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(sportColor.opacity(0.15))
|
||||
.frame(width: 80, height: 80)
|
||||
|
||||
Image(systemName: visit.sportEnum?.iconName ?? "sportscourt")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(sportColor)
|
||||
}
|
||||
|
||||
VStack(spacing: Theme.Spacing.xs) {
|
||||
Text(stadium.name)
|
||||
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.multilineTextAlignment(.center)
|
||||
|
||||
Text(stadium.fullAddress)
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
// Visit type badge
|
||||
Text(visit.visitType.displayName)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, Theme.Spacing.sm)
|
||||
.padding(.vertical, 4)
|
||||
.background(sportColor)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Game Info Card
|
||||
|
||||
private var gameInfoCard: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
HStack {
|
||||
Image(systemName: "sportscourt.fill")
|
||||
.foregroundStyle(sportColor)
|
||||
Text("Game Info")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
if let matchup = visit.matchupDescription {
|
||||
HStack {
|
||||
Text("Matchup")
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
Spacer()
|
||||
Text(matchup)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.fontWeight(.medium)
|
||||
}
|
||||
}
|
||||
|
||||
if let score = visit.finalScore {
|
||||
HStack {
|
||||
Text("Final Score")
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
Spacer()
|
||||
Text(score)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.fontWeight(.bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Details Card
|
||||
|
||||
private var detailsCard: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
HStack {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
Text("Details")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
// Date
|
||||
HStack {
|
||||
Text("Date")
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
Spacer()
|
||||
Text(formattedDate)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
// Seat location
|
||||
if let seat = visit.seatLocation, !seat.isEmpty {
|
||||
HStack {
|
||||
Text("Seat")
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
Spacer()
|
||||
Text(seat)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
// Source
|
||||
HStack {
|
||||
Text("Source")
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
Spacer()
|
||||
Text(visit.source.displayName)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
|
||||
// Created date
|
||||
HStack {
|
||||
Text("Logged")
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
Spacer()
|
||||
Text(formattedCreatedDate)
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notes Card
|
||||
|
||||
private var notesCard: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
HStack {
|
||||
Image(systemName: "note.text")
|
||||
.foregroundStyle(Theme.routeGold)
|
||||
Text("Notes")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .semibold))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
}
|
||||
|
||||
Text(visit.notes ?? "")
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Edit Form
|
||||
|
||||
private var editForm: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.lg) {
|
||||
// Date
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text("Date")
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
DatePicker("", selection: $editVisitDate, displayedComponents: .date)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
// Visit Type
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text("Visit Type")
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
Picker("", selection: $editVisitType) {
|
||||
ForEach(VisitType.allCases, id: \.self) { type in
|
||||
Text(type.displayName).tag(type)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
// Game info (if game type)
|
||||
if editVisitType == .game {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
Text("Game Info")
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
HStack {
|
||||
TextField("Away Team", text: $editAwayTeamName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Text("@")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
TextField("Home Team", text: $editHomeTeamName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
HStack {
|
||||
TextField("Away Score", text: $editAwayScore)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.numberPad)
|
||||
.frame(width: 80)
|
||||
Text("-")
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
TextField("Home Score", text: $editHomeScore)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.keyboardType(.numberPad)
|
||||
.frame(width: 80)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seat location
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text("Seat Location")
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
TextField("e.g., Section 120, Row 5", text: $editSeatLocation)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
}
|
||||
|
||||
// Notes
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
|
||||
Text("Notes")
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
|
||||
TextEditor(text: $editNotes)
|
||||
.frame(minHeight: 100)
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
|
||||
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(Theme.Spacing.lg)
|
||||
.background(Theme.cardBackground(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
|
||||
.stroke(Theme.warmOrange.opacity(0.5), lineWidth: 2)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Delete Button
|
||||
|
||||
private var deleteButton: some View {
|
||||
Button(role: .destructive) {
|
||||
showDeleteConfirmation = true
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
Text("Delete Visit")
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(Theme.Spacing.md)
|
||||
.background(Color.red.opacity(0.1))
|
||||
.foregroundStyle(.red)
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var sportColor: Color {
|
||||
visit.sportEnum?.themeColor ?? Theme.warmOrange
|
||||
}
|
||||
|
||||
private var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .long
|
||||
return formatter.string(from: visit.visitDate)
|
||||
}
|
||||
|
||||
private var formattedCreatedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: visit.createdAt)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func saveChanges() {
|
||||
visit.visitDate = editVisitDate
|
||||
visit.visitType = editVisitType
|
||||
visit.homeTeamName = editHomeTeamName.isEmpty ? nil : editHomeTeamName
|
||||
visit.awayTeamName = editAwayTeamName.isEmpty ? nil : editAwayTeamName
|
||||
visit.seatLocation = editSeatLocation.isEmpty ? nil : editSeatLocation
|
||||
visit.notes = editNotes.isEmpty ? nil : editNotes
|
||||
|
||||
// Update score
|
||||
if let away = Int(editAwayScore), let home = Int(editHomeScore) {
|
||||
visit.finalScore = "\(away)-\(home)"
|
||||
visit.scoreSource = .user
|
||||
} else {
|
||||
visit.finalScore = nil
|
||||
visit.scoreSource = nil
|
||||
}
|
||||
|
||||
// Mark as user corrected if it wasn't fully manual
|
||||
if visit.dataSource != .fullyManual {
|
||||
visit.dataSource = .userCorrected
|
||||
}
|
||||
|
||||
try? modelContext.save()
|
||||
|
||||
withAnimation {
|
||||
isEditing = false
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelEditing() {
|
||||
// Reset to original values
|
||||
editVisitDate = visit.visitDate
|
||||
editVisitType = visit.visitType
|
||||
editHomeTeamName = visit.homeTeamName ?? ""
|
||||
editAwayTeamName = visit.awayTeamName ?? ""
|
||||
editSeatLocation = visit.seatLocation ?? ""
|
||||
editNotes = visit.notes ?? ""
|
||||
|
||||
if let score = visit.finalScore {
|
||||
let parts = score.split(separator: "-")
|
||||
if parts.count == 2 {
|
||||
editAwayScore = String(parts[0])
|
||||
editHomeScore = String(parts[1])
|
||||
}
|
||||
} else {
|
||||
editAwayScore = ""
|
||||
editHomeScore = ""
|
||||
}
|
||||
|
||||
withAnimation {
|
||||
isEditing = false
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteVisit() {
|
||||
modelContext.delete(visit)
|
||||
try? modelContext.save()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Visit Source Display Name
|
||||
|
||||
extension VisitSource {
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .trip: return "From Trip"
|
||||
case .manual: return "Manual Entry"
|
||||
case .photoImport: return "Photo Import"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
|
||||
#Preview {
|
||||
let stadium = Stadium(
|
||||
name: "Oracle Park",
|
||||
city: "San Francisco",
|
||||
state: "CA",
|
||||
latitude: 37.7786,
|
||||
longitude: -122.3893,
|
||||
capacity: 41915,
|
||||
sport: .mlb
|
||||
)
|
||||
|
||||
NavigationStack {
|
||||
Text("Preview placeholder")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ScheduleListView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var viewModel = ScheduleViewModel()
|
||||
@State private var showDatePicker = false
|
||||
|
||||
@@ -97,9 +98,11 @@ struct ScheduleListView: View {
|
||||
Text(formatSectionDate(dateGroup.date))
|
||||
.font(.headline)
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.scrollContentBackground(.hidden)
|
||||
.refreshable {
|
||||
await viewModel.loadGames()
|
||||
}
|
||||
@@ -128,8 +131,7 @@ struct ScheduleListView: View {
|
||||
|
||||
private var loadingView: some View {
|
||||
VStack(spacing: 16) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
ThemedSpinner(size: 44)
|
||||
Text("Loading schedule...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@State private var viewModel = SettingsViewModel()
|
||||
@State private var showResetConfirmation = false
|
||||
|
||||
@@ -91,6 +92,7 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("Choose a color scheme for the app.")
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
// MARK: - Sports Section
|
||||
@@ -115,6 +117,7 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("Selected sports will be shown by default in schedules and trip planning.")
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
// MARK: - Travel Section
|
||||
@@ -159,6 +162,7 @@ struct SettingsView: View {
|
||||
} footer: {
|
||||
Text("Trips will be optimized to keep daily driving within this limit.")
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
// MARK: - Data Section
|
||||
@@ -176,7 +180,7 @@ struct SettingsView: View {
|
||||
Spacer()
|
||||
|
||||
if viewModel.isSyncing {
|
||||
ProgressView()
|
||||
ThemedSpinnerCompact(size: 18)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +213,7 @@ struct SettingsView: View {
|
||||
Text("Schedule data is synced from CloudKit.")
|
||||
#endif
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
// MARK: - About Section
|
||||
@@ -236,6 +241,7 @@ struct SettingsView: View {
|
||||
} header: {
|
||||
Text("About")
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
// MARK: - Reset Section
|
||||
@@ -248,6 +254,7 @@ struct SettingsView: View {
|
||||
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
|
||||
}
|
||||
}
|
||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@@ -348,6 +348,10 @@ final class TripCreationViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
func deselectAllGames() {
|
||||
mustSeeGameIds.removeAll()
|
||||
}
|
||||
|
||||
func switchPlanningMode(_ mode: PlanningMode) {
|
||||
planningMode = mode
|
||||
// Clear mode-specific selections when switching
|
||||
|
||||
@@ -9,11 +9,11 @@ struct TripCreationView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
@Bindable var viewModel: TripCreationViewModel
|
||||
let initialSport: Sport?
|
||||
|
||||
@State private var viewModel = TripCreationViewModel()
|
||||
|
||||
init(initialSport: Sport? = nil) {
|
||||
init(viewModel: TripCreationViewModel, initialSport: Sport? = nil) {
|
||||
self.viewModel = viewModel
|
||||
self.initialSport = initialSport
|
||||
}
|
||||
@State private var showGamePicker = false
|
||||
@@ -25,6 +25,16 @@ struct TripCreationView: View {
|
||||
@State private var completedTrip: Trip?
|
||||
@State private var tripOptions: [ItineraryOption] = []
|
||||
|
||||
// Location search state
|
||||
@State private var startLocationSuggestions: [LocationSearchResult] = []
|
||||
@State private var endLocationSuggestions: [LocationSearchResult] = []
|
||||
@State private var startSearchTask: Task<Void, Never>?
|
||||
@State private var endSearchTask: Task<Void, Never>?
|
||||
@State private var isSearchingStart = false
|
||||
@State private var isSearchingEnd = false
|
||||
|
||||
private let locationService = LocationService.shared
|
||||
|
||||
enum CityInputType {
|
||||
case mustStop
|
||||
case preferred
|
||||
@@ -214,35 +224,192 @@ struct TripCreationView: View {
|
||||
|
||||
private var locationSection: some View {
|
||||
ThemedSection(title: "Locations") {
|
||||
ThemedTextField(
|
||||
label: "Start Location",
|
||||
placeholder: "Where are you starting from?",
|
||||
text: $viewModel.startLocationText,
|
||||
icon: "location.circle.fill"
|
||||
)
|
||||
// Start Location with suggestions
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ThemedTextField(
|
||||
label: "Start Location",
|
||||
placeholder: "Where are you starting from?",
|
||||
text: $viewModel.startLocationText,
|
||||
icon: "location.circle.fill"
|
||||
)
|
||||
.onChange(of: viewModel.startLocationText) { _, newValue in
|
||||
searchLocation(query: newValue, isStart: true)
|
||||
}
|
||||
|
||||
ThemedTextField(
|
||||
label: "End Location",
|
||||
placeholder: "Where do you want to end up?",
|
||||
text: $viewModel.endLocationText,
|
||||
icon: "mappin.circle.fill"
|
||||
)
|
||||
// Suggestions for start location
|
||||
if !startLocationSuggestions.isEmpty {
|
||||
locationSuggestionsList(
|
||||
suggestions: startLocationSuggestions,
|
||||
isLoading: isSearchingStart
|
||||
) { result in
|
||||
viewModel.startLocationText = result.name
|
||||
viewModel.startLocation = result.toLocationInput()
|
||||
startLocationSuggestions = []
|
||||
}
|
||||
} else if isSearchingStart {
|
||||
HStack {
|
||||
ThemedSpinnerCompact(size: 14)
|
||||
Text("Searching...")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(.top, Theme.Spacing.xs)
|
||||
}
|
||||
}
|
||||
|
||||
// End Location with suggestions
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ThemedTextField(
|
||||
label: "End Location",
|
||||
placeholder: "Where do you want to end up?",
|
||||
text: $viewModel.endLocationText,
|
||||
icon: "mappin.circle.fill"
|
||||
)
|
||||
.onChange(of: viewModel.endLocationText) { _, newValue in
|
||||
searchLocation(query: newValue, isStart: false)
|
||||
}
|
||||
|
||||
// Suggestions for end location
|
||||
if !endLocationSuggestions.isEmpty {
|
||||
locationSuggestionsList(
|
||||
suggestions: endLocationSuggestions,
|
||||
isLoading: isSearchingEnd
|
||||
) { result in
|
||||
viewModel.endLocationText = result.name
|
||||
viewModel.endLocation = result.toLocationInput()
|
||||
endLocationSuggestions = []
|
||||
}
|
||||
} else if isSearchingEnd {
|
||||
HStack {
|
||||
ThemedSpinnerCompact(size: 14)
|
||||
Text("Searching...")
|
||||
.font(.system(size: Theme.FontSize.caption))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
.padding(.top, Theme.Spacing.xs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func searchLocation(query: String, isStart: Bool) {
|
||||
// Cancel previous search
|
||||
if isStart {
|
||||
startSearchTask?.cancel()
|
||||
} else {
|
||||
endSearchTask?.cancel()
|
||||
}
|
||||
|
||||
guard query.count >= 2 else {
|
||||
if isStart {
|
||||
startLocationSuggestions = []
|
||||
isSearchingStart = false
|
||||
} else {
|
||||
endLocationSuggestions = []
|
||||
isSearchingEnd = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let task = Task {
|
||||
// Debounce
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
if isStart {
|
||||
isSearchingStart = true
|
||||
} else {
|
||||
isSearchingEnd = true
|
||||
}
|
||||
|
||||
do {
|
||||
let results = try await locationService.searchLocations(query)
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
if isStart {
|
||||
startLocationSuggestions = Array(results.prefix(5))
|
||||
isSearchingStart = false
|
||||
} else {
|
||||
endLocationSuggestions = Array(results.prefix(5))
|
||||
isSearchingEnd = false
|
||||
}
|
||||
} catch {
|
||||
if isStart {
|
||||
startLocationSuggestions = []
|
||||
isSearchingStart = false
|
||||
} else {
|
||||
endLocationSuggestions = []
|
||||
isSearchingEnd = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isStart {
|
||||
startSearchTask = task
|
||||
} else {
|
||||
endSearchTask = task
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func locationSuggestionsList(
|
||||
suggestions: [LocationSearchResult],
|
||||
isLoading: Bool,
|
||||
onSelect: @escaping (LocationSearchResult) -> Void
|
||||
) -> some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(suggestions) { result in
|
||||
Button {
|
||||
onSelect(result)
|
||||
} label: {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
Image(systemName: "mappin.circle.fill")
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
.font(.system(size: 14))
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(result.name)
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
if !result.address.isEmpty {
|
||||
Text(result.address)
|
||||
.font(.system(size: Theme.FontSize.micro))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.padding(.horizontal, Theme.Spacing.xs)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if result.id != suggestions.last?.id {
|
||||
Divider()
|
||||
.overlay(Theme.surfaceGlow(colorScheme))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.top, Theme.Spacing.xs)
|
||||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
|
||||
}
|
||||
|
||||
private var gameBrowserSection: some View {
|
||||
ThemedSection(title: "Select Games") {
|
||||
if viewModel.isLoadingGames || viewModel.availableGames.isEmpty {
|
||||
HStack(spacing: Theme.Spacing.sm) {
|
||||
ProgressView()
|
||||
.tint(Theme.warmOrange)
|
||||
ThemedSpinnerCompact(size: 20)
|
||||
Text("Loading games...")
|
||||
.font(.system(size: Theme.FontSize.body))
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding(.vertical, Theme.Spacing.md)
|
||||
.task {
|
||||
.task(id: viewModel.selectedSports) {
|
||||
// Re-run when sports selection changes
|
||||
if viewModel.availableGames.isEmpty {
|
||||
await viewModel.loadGamesForBrowsing()
|
||||
}
|
||||
@@ -290,6 +457,16 @@ struct TripCreationView: View {
|
||||
Text("\(viewModel.mustSeeGameIds.count) game(s) selected")
|
||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
viewModel.deselectAllGames()
|
||||
} label: {
|
||||
Text("Deselect All")
|
||||
.font(.system(size: Theme.FontSize.caption, weight: .medium))
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
|
||||
// Show selected games preview
|
||||
@@ -927,8 +1104,7 @@ struct LocationSearchSheet: View {
|
||||
.textFieldStyle(.plain)
|
||||
.autocorrectionDisabled()
|
||||
if isSearching {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
ThemedSpinnerCompact(size: 16)
|
||||
} else if !searchText.isEmpty {
|
||||
Button {
|
||||
searchText = ""
|
||||
@@ -1260,8 +1436,7 @@ struct TripOptionCard: View {
|
||||
.transition(.opacity)
|
||||
} else if isLoadingDescription {
|
||||
HStack(spacing: 4) {
|
||||
ProgressView()
|
||||
.scaleEffect(0.6)
|
||||
ThemedSpinnerCompact(size: 12)
|
||||
Text("Generating...")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||
@@ -1806,5 +1981,5 @@ struct SportSelectionChip: View {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
TripCreationView()
|
||||
TripCreationView(viewModel: TripCreationViewModel())
|
||||
}
|
||||
|
||||
@@ -198,8 +198,7 @@ struct TripDetailView: View {
|
||||
|
||||
// Loading indicator
|
||||
if isLoadingRoutes {
|
||||
ProgressView()
|
||||
.tint(Theme.warmOrange)
|
||||
ThemedSpinnerCompact(size: 24)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
}
|
||||
|
||||
227
SportsTime/Resources/league_structure.json
Normal file
227
SportsTime/Resources/league_structure.json
Normal file
@@ -0,0 +1,227 @@
|
||||
[
|
||||
{
|
||||
"id": "mlb_league",
|
||||
"sport": "MLB",
|
||||
"type": "league",
|
||||
"name": "Major League Baseball",
|
||||
"abbreviation": "MLB",
|
||||
"parent_id": null,
|
||||
"display_order": 0
|
||||
},
|
||||
{
|
||||
"id": "mlb_al",
|
||||
"sport": "MLB",
|
||||
"type": "conference",
|
||||
"name": "American League",
|
||||
"abbreviation": "AL",
|
||||
"parent_id": "mlb_league",
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"id": "mlb_nl",
|
||||
"sport": "MLB",
|
||||
"type": "conference",
|
||||
"name": "National League",
|
||||
"abbreviation": "NL",
|
||||
"parent_id": "mlb_league",
|
||||
"display_order": 2
|
||||
},
|
||||
{
|
||||
"id": "mlb_al_east",
|
||||
"sport": "MLB",
|
||||
"type": "division",
|
||||
"name": "AL East",
|
||||
"abbreviation": null,
|
||||
"parent_id": "mlb_al",
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"id": "mlb_al_central",
|
||||
"sport": "MLB",
|
||||
"type": "division",
|
||||
"name": "AL Central",
|
||||
"abbreviation": null,
|
||||
"parent_id": "mlb_al",
|
||||
"display_order": 2
|
||||
},
|
||||
{
|
||||
"id": "mlb_al_west",
|
||||
"sport": "MLB",
|
||||
"type": "division",
|
||||
"name": "AL West",
|
||||
"abbreviation": null,
|
||||
"parent_id": "mlb_al",
|
||||
"display_order": 3
|
||||
},
|
||||
{
|
||||
"id": "mlb_nl_east",
|
||||
"sport": "MLB",
|
||||
"type": "division",
|
||||
"name": "NL East",
|
||||
"abbreviation": null,
|
||||
"parent_id": "mlb_nl",
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"id": "mlb_nl_central",
|
||||
"sport": "MLB",
|
||||
"type": "division",
|
||||
"name": "NL Central",
|
||||
"abbreviation": null,
|
||||
"parent_id": "mlb_nl",
|
||||
"display_order": 2
|
||||
},
|
||||
{
|
||||
"id": "mlb_nl_west",
|
||||
"sport": "MLB",
|
||||
"type": "division",
|
||||
"name": "NL West",
|
||||
"abbreviation": null,
|
||||
"parent_id": "mlb_nl",
|
||||
"display_order": 3
|
||||
},
|
||||
{
|
||||
"id": "nba_league",
|
||||
"sport": "NBA",
|
||||
"type": "league",
|
||||
"name": "National Basketball Association",
|
||||
"abbreviation": "NBA",
|
||||
"parent_id": null,
|
||||
"display_order": 0
|
||||
},
|
||||
{
|
||||
"id": "nba_eastern",
|
||||
"sport": "NBA",
|
||||
"type": "conference",
|
||||
"name": "Eastern Conference",
|
||||
"abbreviation": "East",
|
||||
"parent_id": "nba_league",
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"id": "nba_western",
|
||||
"sport": "NBA",
|
||||
"type": "conference",
|
||||
"name": "Western Conference",
|
||||
"abbreviation": "West",
|
||||
"parent_id": "nba_league",
|
||||
"display_order": 2
|
||||
},
|
||||
{
|
||||
"id": "nba_atlantic",
|
||||
"sport": "NBA",
|
||||
"type": "division",
|
||||
"name": "Atlantic",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nba_eastern",
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"id": "nba_central",
|
||||
"sport": "NBA",
|
||||
"type": "division",
|
||||
"name": "Central",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nba_eastern",
|
||||
"display_order": 2
|
||||
},
|
||||
{
|
||||
"id": "nba_southeast",
|
||||
"sport": "NBA",
|
||||
"type": "division",
|
||||
"name": "Southeast",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nba_eastern",
|
||||
"display_order": 3
|
||||
},
|
||||
{
|
||||
"id": "nba_northwest",
|
||||
"sport": "NBA",
|
||||
"type": "division",
|
||||
"name": "Northwest",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nba_western",
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"id": "nba_pacific",
|
||||
"sport": "NBA",
|
||||
"type": "division",
|
||||
"name": "Pacific",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nba_western",
|
||||
"display_order": 2
|
||||
},
|
||||
{
|
||||
"id": "nba_southwest",
|
||||
"sport": "NBA",
|
||||
"type": "division",
|
||||
"name": "Southwest",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nba_western",
|
||||
"display_order": 3
|
||||
},
|
||||
{
|
||||
"id": "nhl_league",
|
||||
"sport": "NHL",
|
||||
"type": "league",
|
||||
"name": "National Hockey League",
|
||||
"abbreviation": "NHL",
|
||||
"parent_id": null,
|
||||
"display_order": 0
|
||||
},
|
||||
{
|
||||
"id": "nhl_eastern",
|
||||
"sport": "NHL",
|
||||
"type": "conference",
|
||||
"name": "Eastern Conference",
|
||||
"abbreviation": "East",
|
||||
"parent_id": "nhl_league",
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"id": "nhl_western",
|
||||
"sport": "NHL",
|
||||
"type": "conference",
|
||||
"name": "Western Conference",
|
||||
"abbreviation": "West",
|
||||
"parent_id": "nhl_league",
|
||||
"display_order": 2
|
||||
},
|
||||
{
|
||||
"id": "nhl_atlantic",
|
||||
"sport": "NHL",
|
||||
"type": "division",
|
||||
"name": "Atlantic",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nhl_eastern",
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"id": "nhl_metropolitan",
|
||||
"sport": "NHL",
|
||||
"type": "division",
|
||||
"name": "Metropolitan",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nhl_eastern",
|
||||
"display_order": 2
|
||||
},
|
||||
{
|
||||
"id": "nhl_central",
|
||||
"sport": "NHL",
|
||||
"type": "division",
|
||||
"name": "Central",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nhl_western",
|
||||
"display_order": 1
|
||||
},
|
||||
{
|
||||
"id": "nhl_pacific",
|
||||
"sport": "NHL",
|
||||
"type": "division",
|
||||
"name": "Pacific",
|
||||
"abbreviation": null,
|
||||
"parent_id": "nhl_western",
|
||||
"display_order": 2
|
||||
}
|
||||
]
|
||||
306
SportsTime/Resources/team_aliases.json
Normal file
306
SportsTime/Resources/team_aliases.json
Normal file
@@ -0,0 +1,306 @@
|
||||
[
|
||||
{
|
||||
"id": "alias_nba_brk_njn",
|
||||
"team_canonical_id": "team_nba_brk",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "NJN",
|
||||
"valid_from": null,
|
||||
"valid_until": "2012-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_brk_nj_nets",
|
||||
"team_canonical_id": "team_nba_brk",
|
||||
"alias_type": "name",
|
||||
"alias_value": "New Jersey Nets",
|
||||
"valid_from": null,
|
||||
"valid_until": "2012-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_brk_nj_city",
|
||||
"team_canonical_id": "team_nba_brk",
|
||||
"alias_type": "city",
|
||||
"alias_value": "New Jersey",
|
||||
"valid_from": null,
|
||||
"valid_until": "2012-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_okc_sea",
|
||||
"team_canonical_id": "team_nba_okc",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "SEA",
|
||||
"valid_from": null,
|
||||
"valid_until": "2008-07-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_okc_sonics",
|
||||
"team_canonical_id": "team_nba_okc",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Seattle SuperSonics",
|
||||
"valid_from": null,
|
||||
"valid_until": "2008-07-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_okc_seattle",
|
||||
"team_canonical_id": "team_nba_okc",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Seattle",
|
||||
"valid_from": null,
|
||||
"valid_until": "2008-07-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_mem_van",
|
||||
"team_canonical_id": "team_nba_mem",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "VAN",
|
||||
"valid_from": null,
|
||||
"valid_until": "2001-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_mem_vancouver",
|
||||
"team_canonical_id": "team_nba_mem",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Vancouver Grizzlies",
|
||||
"valid_from": null,
|
||||
"valid_until": "2001-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_nop_noh",
|
||||
"team_canonical_id": "team_nba_nop",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "NOH",
|
||||
"valid_from": "2002-01-01T00:00:00Z",
|
||||
"valid_until": "2013-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_nop_hornets",
|
||||
"team_canonical_id": "team_nba_nop",
|
||||
"alias_type": "name",
|
||||
"alias_value": "New Orleans Hornets",
|
||||
"valid_from": "2002-01-01T00:00:00Z",
|
||||
"valid_until": "2013-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_cho_cha_old",
|
||||
"team_canonical_id": "team_nba_cho",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "CHA",
|
||||
"valid_from": "2014-01-01T00:00:00Z",
|
||||
"valid_until": null
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_was_wsh",
|
||||
"team_canonical_id": "team_nba_was",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "WSH",
|
||||
"valid_from": null,
|
||||
"valid_until": null
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_uta_utj",
|
||||
"team_canonical_id": "team_nba_uta",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "UTJ",
|
||||
"valid_from": null,
|
||||
"valid_until": null
|
||||
},
|
||||
{
|
||||
"id": "alias_nba_pho_phx",
|
||||
"team_canonical_id": "team_nba_pho",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "PHX",
|
||||
"valid_from": null,
|
||||
"valid_until": null
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_mia_fla",
|
||||
"team_canonical_id": "team_mlb_mia",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "FLA",
|
||||
"valid_from": null,
|
||||
"valid_until": "2012-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_mia_marlins_fl",
|
||||
"team_canonical_id": "team_mlb_mia",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Florida Marlins",
|
||||
"valid_from": null,
|
||||
"valid_until": "2012-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_mia_florida",
|
||||
"team_canonical_id": "team_mlb_mia",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Florida",
|
||||
"valid_from": null,
|
||||
"valid_until": "2012-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_laa_ana",
|
||||
"team_canonical_id": "team_mlb_laa",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "ANA",
|
||||
"valid_from": null,
|
||||
"valid_until": "2005-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_laa_angels_ana",
|
||||
"team_canonical_id": "team_mlb_laa",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Anaheim Angels",
|
||||
"valid_from": null,
|
||||
"valid_until": "2005-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_laa_california",
|
||||
"team_canonical_id": "team_mlb_laa",
|
||||
"alias_type": "name",
|
||||
"alias_value": "California Angels",
|
||||
"valid_from": null,
|
||||
"valid_until": "1996-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_tbr_tbd",
|
||||
"team_canonical_id": "team_mlb_tbr",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "TBD",
|
||||
"valid_from": null,
|
||||
"valid_until": "2008-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_tbr_devil_rays",
|
||||
"team_canonical_id": "team_mlb_tbr",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Tampa Bay Devil Rays",
|
||||
"valid_from": null,
|
||||
"valid_until": "2008-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_was_mon",
|
||||
"team_canonical_id": "team_mlb_was",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "MON",
|
||||
"valid_from": null,
|
||||
"valid_until": "2005-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_was_expos",
|
||||
"team_canonical_id": "team_mlb_was",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Montreal Expos",
|
||||
"valid_from": null,
|
||||
"valid_until": "2005-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_mlb_was_montreal",
|
||||
"team_canonical_id": "team_mlb_was",
|
||||
"alias_type": "city",
|
||||
"alias_value": "Montreal",
|
||||
"valid_from": null,
|
||||
"valid_until": "2005-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_ari_pho",
|
||||
"team_canonical_id": "team_nhl_uta",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "ARI",
|
||||
"valid_from": "1996-01-01T00:00:00Z",
|
||||
"valid_until": "2024-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_ari_coyotes",
|
||||
"team_canonical_id": "team_nhl_uta",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Arizona Coyotes",
|
||||
"valid_from": "1996-01-01T00:00:00Z",
|
||||
"valid_until": "2024-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_ari_phoenix",
|
||||
"team_canonical_id": "team_nhl_uta",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Phoenix Coyotes",
|
||||
"valid_from": "1996-01-01T00:00:00Z",
|
||||
"valid_until": "2014-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_wpg_atl",
|
||||
"team_canonical_id": "team_nhl_wpg",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "ATL",
|
||||
"valid_from": "1999-01-01T00:00:00Z",
|
||||
"valid_until": "2011-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_wpg_thrashers",
|
||||
"team_canonical_id": "team_nhl_wpg",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Atlanta Thrashers",
|
||||
"valid_from": "1999-01-01T00:00:00Z",
|
||||
"valid_until": "2011-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_car_htf",
|
||||
"team_canonical_id": "team_nhl_car",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "HTF",
|
||||
"valid_from": null,
|
||||
"valid_until": "1997-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_car_whalers",
|
||||
"team_canonical_id": "team_nhl_car",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Hartford Whalers",
|
||||
"valid_from": null,
|
||||
"valid_until": "1997-01-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_col_que",
|
||||
"team_canonical_id": "team_nhl_col",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "QUE",
|
||||
"valid_from": null,
|
||||
"valid_until": "1995-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_col_nordiques",
|
||||
"team_canonical_id": "team_nhl_col",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Quebec Nordiques",
|
||||
"valid_from": null,
|
||||
"valid_until": "1995-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_dal_mns",
|
||||
"team_canonical_id": "team_nhl_dal",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "MNS",
|
||||
"valid_from": null,
|
||||
"valid_until": "1993-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_dal_north_stars",
|
||||
"team_canonical_id": "team_nhl_dal",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Minnesota North Stars",
|
||||
"valid_from": null,
|
||||
"valid_until": "1993-05-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_ana_mda",
|
||||
"team_canonical_id": "team_nhl_ana",
|
||||
"alias_type": "name",
|
||||
"alias_value": "Mighty Ducks of Anaheim",
|
||||
"valid_from": null,
|
||||
"valid_until": "2006-06-01T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "alias_nhl_vgk_lv",
|
||||
"team_canonical_id": "team_nhl_vgk",
|
||||
"alias_type": "abbreviation",
|
||||
"alias_value": "LV",
|
||||
"valid_from": null,
|
||||
"valid_until": null
|
||||
}
|
||||
]
|
||||
@@ -12,10 +12,24 @@ import SwiftData
|
||||
struct SportsTimeApp: App {
|
||||
var sharedModelContainer: ModelContainer = {
|
||||
let schema = Schema([
|
||||
// User data models
|
||||
SavedTrip.self,
|
||||
TripVote.self,
|
||||
UserPreferences.self,
|
||||
CachedSchedule.self,
|
||||
// Stadium progress models
|
||||
StadiumVisit.self,
|
||||
VisitPhotoMetadata.self,
|
||||
Achievement.self,
|
||||
CachedGameScore.self,
|
||||
// Canonical data models
|
||||
SyncState.self,
|
||||
CanonicalStadium.self,
|
||||
StadiumAlias.self,
|
||||
CanonicalTeam.self,
|
||||
TeamAlias.self,
|
||||
LeagueStructureModel.self,
|
||||
CanonicalGame.self,
|
||||
])
|
||||
let modelConfiguration = ModelConfiguration(
|
||||
schema: schema,
|
||||
@@ -32,8 +46,100 @@ struct SportsTimeApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
HomeView()
|
||||
BootstrappedContentView(modelContainer: sharedModelContainer)
|
||||
}
|
||||
.modelContainer(sharedModelContainer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bootstrapped Content View
|
||||
|
||||
/// Wraps the main content with bootstrap logic.
|
||||
/// Shows a loading indicator until bootstrap completes, then shows HomeView.
|
||||
struct BootstrappedContentView: View {
|
||||
let modelContainer: ModelContainer
|
||||
|
||||
@State private var isBootstrapping = true
|
||||
@State private var bootstrapError: Error?
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if isBootstrapping {
|
||||
BootstrapLoadingView()
|
||||
} else if let error = bootstrapError {
|
||||
BootstrapErrorView(error: error) {
|
||||
Task {
|
||||
await performBootstrap()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HomeView()
|
||||
}
|
||||
}
|
||||
.task {
|
||||
await performBootstrap()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func performBootstrap() async {
|
||||
isBootstrapping = true
|
||||
bootstrapError = nil
|
||||
|
||||
let context = modelContainer.mainContext
|
||||
let bootstrapService = BootstrapService()
|
||||
|
||||
do {
|
||||
try await bootstrapService.bootstrapIfNeeded(context: context)
|
||||
isBootstrapping = false
|
||||
} catch {
|
||||
bootstrapError = error
|
||||
isBootstrapping = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap Loading View
|
||||
|
||||
struct BootstrapLoadingView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
ThemedSpinner(size: 50, lineWidth: 4)
|
||||
|
||||
Text("Setting up SportsTime...")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap Error View
|
||||
|
||||
struct BootstrapErrorView: View {
|
||||
let error: Error
|
||||
let onRetry: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 50))
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
Text("Setup Failed")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text(error.localizedDescription)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
Button("Try Again") {
|
||||
onRetry()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ struct ScenarioAPlannerSwiftTests {
|
||||
id: UUID = UUID(),
|
||||
city: String,
|
||||
latitude: Double,
|
||||
longitude: Double
|
||||
longitude: Double,
|
||||
sport: Sport = .mlb
|
||||
) -> Stadium {
|
||||
Stadium(
|
||||
id: id,
|
||||
@@ -30,7 +31,8 @@ struct ScenarioAPlannerSwiftTests {
|
||||
state: "ST",
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
capacity: 40000
|
||||
capacity: 40000,
|
||||
sport: sport
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ struct ScenarioBPlannerTests {
|
||||
city: String,
|
||||
state: String,
|
||||
latitude: Double,
|
||||
longitude: Double
|
||||
longitude: Double,
|
||||
sport: Sport = .mlb
|
||||
) -> Stadium {
|
||||
Stadium(
|
||||
id: id,
|
||||
@@ -31,7 +32,8 @@ struct ScenarioBPlannerTests {
|
||||
state: state,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
capacity: 40000
|
||||
capacity: 40000,
|
||||
sport: sport
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ struct ScenarioCPlannerTests {
|
||||
city: String,
|
||||
state: String,
|
||||
latitude: Double,
|
||||
longitude: Double
|
||||
longitude: Double,
|
||||
sport: Sport = .mlb
|
||||
) -> Stadium {
|
||||
Stadium(
|
||||
id: id,
|
||||
@@ -32,7 +33,8 @@ struct ScenarioCPlannerTests {
|
||||
state: state,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
capacity: 40000
|
||||
capacity: 40000,
|
||||
sport: sport
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,8 @@ struct DayCardTests {
|
||||
state: "ST",
|
||||
latitude: 40.0,
|
||||
longitude: -100.0,
|
||||
capacity: 40000
|
||||
capacity: 40000,
|
||||
sport: game.sport
|
||||
)
|
||||
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
||||
}
|
||||
@@ -350,7 +351,7 @@ struct DayCardTests {
|
||||
/// Tests for handling duplicate game IDs without crashing (regression test for fatal error)
|
||||
struct DuplicateGameIdTests {
|
||||
|
||||
private func makeStadium() -> Stadium {
|
||||
private func makeStadium(sport: Sport = .mlb) -> Stadium {
|
||||
Stadium(
|
||||
id: UUID(),
|
||||
name: "Test Stadium",
|
||||
@@ -358,7 +359,8 @@ struct DuplicateGameIdTests {
|
||||
state: "TS",
|
||||
latitude: 40.0,
|
||||
longitude: -100.0,
|
||||
capacity: 40000
|
||||
capacity: 40000,
|
||||
sport: sport
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
841
docs/STADIUM_PROGRESS_SPEC.md
Normal file
841
docs/STADIUM_PROGRESS_SPEC.md
Normal file
@@ -0,0 +1,841 @@
|
||||
# Stadium Progress & Achievement System
|
||||
|
||||
## Overview
|
||||
|
||||
Track stadium visits, visualize progress on a map, earn achievement badges, and share progress cards. Supports MLB, NBA, NHL with historical visit logging and photo attachments synced to iCloud.
|
||||
|
||||
## User Requirements
|
||||
|
||||
- **Visual Progress Map**: Interactive map showing visited/unvisited stadiums, filterable by league
|
||||
- **Shareable Progress Cards**: Social media export with stats and optional map snapshot
|
||||
- **Achievement Badges**: Count-based, regional (division), journey-based, league completion
|
||||
- **Visit Logging**: Auto-fill from app games, manual/historical entry, stadium-only visits (tours)
|
||||
- **Photo Attachments**: Multiple photos per visit, iCloud sync for backup
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Decision | Choice |
|
||||
|----------|--------|
|
||||
| Photo storage | iCloud sync (CloudKit private database) |
|
||||
| Historical games | Manual entry fallback + stadium-only visits allowed |
|
||||
| Achievement policy | Recalculate & revoke when visits deleted |
|
||||
|
||||
---
|
||||
|
||||
## Data Models
|
||||
|
||||
### SwiftData Models
|
||||
|
||||
**File**: `SportsTime/Core/Models/Local/StadiumProgress.swift`
|
||||
|
||||
```swift
|
||||
@Model
|
||||
final class StadiumVisit {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var canonicalStadiumId: UUID // Stable ID across renames
|
||||
var stadiumNameAtVisit: String // Frozen at visit time
|
||||
var visitDate: Date
|
||||
var sport: String // Sport.rawValue
|
||||
var visitType: String // "game" | "tour" | "other"
|
||||
|
||||
// Game info (optional)
|
||||
var gameId: UUID?
|
||||
var homeTeamId: UUID?
|
||||
var awayTeamId: UUID?
|
||||
var homeTeamName: String? // For display when team lookup fails
|
||||
var awayTeamName: String?
|
||||
var finalScore: String? // "5-3" format
|
||||
var manualGameDescription: String? // User's description if game not found
|
||||
|
||||
// Resolution tracking
|
||||
var scoreSource: String // "app" | "api" | "scraped" | "user"
|
||||
var dataSource: String // "automatic" | "partial_manual" | "fully_manual" | "user_corrected"
|
||||
var scoreResolutionPending: Bool // true if background retry needed
|
||||
|
||||
// User data
|
||||
var seatLocation: String?
|
||||
var notes: String?
|
||||
|
||||
// Photos
|
||||
@Relationship(deleteRule: .cascade)
|
||||
var photoMetadata: [VisitPhotoMetadata]?
|
||||
|
||||
// Photo import metadata (preserved for debugging/re-matching)
|
||||
var photoLatitude: Double?
|
||||
var photoLongitude: Double?
|
||||
var photoCaptureDate: Date?
|
||||
|
||||
var createdAt: Date
|
||||
var source: String // "trip" | "manual" | "photo_import"
|
||||
}
|
||||
|
||||
@Model
|
||||
final class VisitPhotoMetadata {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var visitId: UUID
|
||||
var cloudKitAssetId: String
|
||||
var thumbnailData: Data? // 200x200 JPEG
|
||||
var caption: String?
|
||||
var orderIndex: Int
|
||||
var uploadStatus: String // "pending" | "uploaded" | "failed"
|
||||
}
|
||||
|
||||
@Model
|
||||
final class Achievement {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var achievementTypeId: String // "mlb_all_30" | "nl_west"
|
||||
var sport: String?
|
||||
var earnedAt: Date
|
||||
var revokedAt: Date? // Non-nil if visits deleted
|
||||
var visitIdsSnapshot: Data // [UUID] that earned this
|
||||
}
|
||||
```
|
||||
|
||||
### Domain Models
|
||||
|
||||
**File**: `SportsTime/Core/Models/Domain/Division.swift`
|
||||
|
||||
```swift
|
||||
struct Division: Identifiable, Codable {
|
||||
let id: String // "MLB_NL_WEST"
|
||||
let name: String // "NL West"
|
||||
let conference: String // "National League"
|
||||
let sport: Sport
|
||||
let teamIds: [UUID]
|
||||
}
|
||||
|
||||
enum LeagueStructure {
|
||||
static let mlbDivisions: [Division] // 6 divisions
|
||||
static let nbaDivisions: [Division] // 6 divisions
|
||||
static let nhlDivisions: [Division] // 4 divisions
|
||||
|
||||
static func divisions(for sport: Sport) -> [Division]
|
||||
static func division(forTeam teamId: UUID) -> Division?
|
||||
}
|
||||
```
|
||||
|
||||
**File**: `SportsTime/Core/Models/Domain/Progress.swift`
|
||||
|
||||
```swift
|
||||
struct LeagueProgress {
|
||||
let sport: Sport
|
||||
let totalStadiums: Int
|
||||
let visitedStadiums: Int
|
||||
let stadiumsVisited: [Stadium]
|
||||
let stadiumsRemaining: [Stadium]
|
||||
let completionPercentage: Double
|
||||
let divisionProgress: [DivisionProgress]
|
||||
}
|
||||
|
||||
enum VisitType: String, Codable, CaseIterable {
|
||||
case game, tour, other
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Stadium Identity Strategy
|
||||
|
||||
**Problem**: Stadiums rename (SBC Park → AT&T Park → Oracle Park). Same physical location should count as one visit.
|
||||
|
||||
**Solution**: Canonical stadium IDs stored in bundled JSON.
|
||||
|
||||
**File**: `SportsTime/Core/Services/StadiumIdentityService.swift`
|
||||
|
||||
```swift
|
||||
actor StadiumIdentityService {
|
||||
static let shared = StadiumIdentityService()
|
||||
|
||||
func canonicalId(for stadiumId: UUID) -> UUID
|
||||
func canonicalId(forName name: String) -> UUID?
|
||||
func isSameStadium(_ id1: UUID, _ id2: UUID) -> Bool
|
||||
}
|
||||
```
|
||||
|
||||
**File**: `Resources/stadium_identities.json`
|
||||
|
||||
```json
|
||||
[{
|
||||
"canonicalId": "...",
|
||||
"currentName": "Oracle Park",
|
||||
"allNames": ["Oracle Park", "AT&T Park", "SBC Park"],
|
||||
"stadiumUUIDs": ["uuid1", "uuid2"],
|
||||
"sport": "MLB",
|
||||
"openedYear": 2000,
|
||||
"closedYear": null
|
||||
}]
|
||||
```
|
||||
|
||||
| Scenario | Handling |
|
||||
|----------|----------|
|
||||
| Stadium renamed | All names map to same canonicalId |
|
||||
| Team relocated | Old stadium gets closedYear, new is separate |
|
||||
| Demolished stadium | Still counts for historical visits |
|
||||
| Shared stadium (Jets/Giants) | Single canonicalId, multiple teamIds |
|
||||
|
||||
---
|
||||
|
||||
## Achievement System
|
||||
|
||||
**File**: `SportsTime/Core/Models/Domain/AchievementDefinitions.swift`
|
||||
|
||||
### Achievement Types
|
||||
|
||||
| Category | Examples |
|
||||
|----------|----------|
|
||||
| Count | "First Pitch" (1), "Double Digits" (10), "Veteran Fan" (20) |
|
||||
| Division | "NL West Champion", "AFC North Complete" |
|
||||
| Conference | "National League Complete" |
|
||||
| League | "Diamond Collector" (all 30 MLB) |
|
||||
| Journey | "Road Warrior" (5 in 7 days), "Triple Threat" (3 leagues) |
|
||||
|
||||
### Achievement Engine
|
||||
|
||||
**File**: `SportsTime/Core/Services/AchievementEngine.swift`
|
||||
|
||||
```swift
|
||||
actor AchievementEngine {
|
||||
/// Full recalculation (call after visit deleted)
|
||||
func recalculateAllAchievements() async throws -> AchievementDelta
|
||||
|
||||
/// Quick check after new visit
|
||||
func checkAchievementsForNewVisit(_ visit: StadiumVisit) async throws -> [AchievementDefinition]
|
||||
}
|
||||
```
|
||||
|
||||
**Recalculation triggers**:
|
||||
- Visit added → incremental check
|
||||
- Visit deleted → full recalculation (may revoke)
|
||||
- App update with new achievements → full recalculation
|
||||
|
||||
---
|
||||
|
||||
## Canonical Game Identity
|
||||
|
||||
**Problem**: Scraped games, API games, and app games will disagree on IDs, team naming, and sometimes scores (corrections). Need a stable, derived key.
|
||||
|
||||
**File**: `SportsTime/Core/Models/Domain/CanonicalGameKey.swift`
|
||||
|
||||
```swift
|
||||
/// Derived, stable key that prevents duplicates and handles score drift
|
||||
struct CanonicalGameKey: Hashable, Codable {
|
||||
let sport: Sport
|
||||
let stadiumCanonicalId: UUID
|
||||
let gameDate: Date // Normalized to local stadium date
|
||||
let homeTeamCanonicalId: UUID
|
||||
}
|
||||
|
||||
/// Resolved game with source tracking
|
||||
struct ResolvedGame {
|
||||
let canonicalKey: CanonicalGameKey
|
||||
let rawSourceId: String? // Original ID from source
|
||||
let resolutionSource: ResolutionSource
|
||||
|
||||
// Game data
|
||||
let homeTeamName: String
|
||||
let awayTeamName: String
|
||||
let homeScore: Int?
|
||||
let awayScore: Int?
|
||||
|
||||
// Audit trail
|
||||
let resolvedAt: Date
|
||||
let sourceVersion: String? // API version or scrape date
|
||||
}
|
||||
```
|
||||
|
||||
**Key normalization rules**:
|
||||
- `gameDate` normalized to stadium's local timezone midnight
|
||||
- `stadiumCanonicalId` from StadiumIdentityService
|
||||
- `homeTeamCanonicalId` from team alias mapping
|
||||
|
||||
Every resolution path (app data, API, scrape, user) must map to `CanonicalGameKey`.
|
||||
|
||||
---
|
||||
|
||||
## Historical Game Resolution
|
||||
|
||||
**File**: `SportsTime/Core/Services/HistoricalGameService.swift`
|
||||
|
||||
**Strategy** (in order):
|
||||
1. **Bundled indexes** - Date → stadium → home team lookup (no scores)
|
||||
2. **Lazy-fetch scores** - From API/scrape on first access, cache forever
|
||||
3. **Manual entry** - User describes game, marked "user verified"
|
||||
|
||||
```swift
|
||||
actor HistoricalGameService {
|
||||
func searchGames(query: HistoricalGameQuery) async throws -> [HistoricalGameResult]
|
||||
func resolveCanonicalKey(from result: HistoricalGameResult) -> CanonicalGameKey
|
||||
}
|
||||
|
||||
struct HistoricalGameQuery {
|
||||
let sport: Sport
|
||||
let date: Date
|
||||
let stadiumCanonicalId: UUID? // If known from photo
|
||||
let homeTeamName: String?
|
||||
let awayTeamName: String?
|
||||
}
|
||||
```
|
||||
|
||||
### Bundled Data Strategy (Size-Conscious)
|
||||
|
||||
**Problem**: `historical_games_mlb.json` at ~5MB/sport will explode over time.
|
||||
|
||||
**Solution**: Bundle indexes only, lazy-fetch scores.
|
||||
|
||||
**File**: `Resources/historical_game_index_mlb.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"2010-06-15": {
|
||||
"stadium-uuid-oracle-park": {
|
||||
"homeTeamId": "team-uuid-sfg",
|
||||
"awayTeamId": "team-uuid-lad"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Size**: ~500KB/sport (dates + stadium + teams only)
|
||||
|
||||
**Score fetching**: On first lookup, fetch from API/scrape, cache in `CachedGameScore` forever.
|
||||
|
||||
---
|
||||
|
||||
## Photo-Based Import Pipeline
|
||||
|
||||
**Primary method for logging historical visits**: Import photos from library, extract metadata, auto-resolve game.
|
||||
|
||||
### Photo Metadata Extraction
|
||||
|
||||
**File**: `SportsTime/Core/Services/PhotoMetadataExtractor.swift`
|
||||
|
||||
```swift
|
||||
struct PhotoMetadata {
|
||||
let captureDate: Date?
|
||||
let coordinates: CLLocationCoordinate2D?
|
||||
let hasValidLocation: Bool
|
||||
let hasValidDate: Bool
|
||||
}
|
||||
|
||||
actor PhotoMetadataExtractor {
|
||||
/// Extract EXIF data from PHAsset
|
||||
func extractMetadata(from asset: PHAsset) async -> PhotoMetadata
|
||||
|
||||
/// Extract from UIImage with ImageIO (fallback)
|
||||
func extractMetadata(from imageData: Data) -> PhotoMetadata
|
||||
}
|
||||
```
|
||||
|
||||
**Extraction sources** (in order):
|
||||
1. `PHAsset.location` and `PHAsset.creationDate` (preferred)
|
||||
2. EXIF via ImageIO: `kCGImagePropertyGPSLatitude`, `kCGImagePropertyExifDateTimeOriginal`
|
||||
3. File creation date (last resort, unreliable)
|
||||
|
||||
### Stadium Proximity Matching
|
||||
|
||||
**File**: `SportsTime/Core/Services/StadiumProximityMatcher.swift`
|
||||
|
||||
```swift
|
||||
struct StadiumMatch {
|
||||
let stadium: Stadium
|
||||
let distance: CLLocationDistance
|
||||
let confidence: MatchConfidence
|
||||
}
|
||||
|
||||
enum MatchConfidence {
|
||||
case high // < 500m from stadium center
|
||||
case medium // 500m - 2km
|
||||
case low // 2km - 5km
|
||||
case none // > 5km or no coordinates
|
||||
}
|
||||
|
||||
actor StadiumProximityMatcher {
|
||||
/// Find stadiums within radius of coordinates
|
||||
func findNearbyStadiums(
|
||||
coordinates: CLLocationCoordinate2D,
|
||||
radius: CLLocationDistance = 5000 // 5km default
|
||||
) async -> [StadiumMatch]
|
||||
}
|
||||
```
|
||||
|
||||
**Configurable parameters**:
|
||||
- `highConfidenceRadius`: 500m (auto-select threshold)
|
||||
- `searchRadius`: 5km (maximum search distance)
|
||||
- `dateToleranceDays`: 1 (for doubleheaders, timezone issues)
|
||||
|
||||
### Temporal Confidence
|
||||
|
||||
**Problem**: Photos taken hours before game, next morning, or during tailgating can still be valid.
|
||||
|
||||
```swift
|
||||
enum TemporalConfidence {
|
||||
case exactDay // Same local date as game
|
||||
case adjacentDay // ±1 day (tailgating, next morning)
|
||||
case outOfRange // >1 day difference
|
||||
}
|
||||
|
||||
struct PhotoMatchConfidence {
|
||||
let spatial: MatchConfidence // Distance-based
|
||||
let temporal: TemporalConfidence // Time-based
|
||||
let combined: CombinedConfidence // Final score
|
||||
}
|
||||
|
||||
enum CombinedConfidence {
|
||||
case autoSelect // High spatial + exactDay → auto-select
|
||||
case userConfirm // Medium spatial OR adjacentDay → user confirms
|
||||
case manualOnly // Low spatial OR outOfRange → manual entry
|
||||
}
|
||||
```
|
||||
|
||||
**Combination rules**:
|
||||
|
||||
| Spatial | Temporal | Result |
|
||||
|---------|----------|--------|
|
||||
| high | exactDay | autoSelect |
|
||||
| high | adjacentDay | userConfirm |
|
||||
| medium | exactDay | userConfirm |
|
||||
| medium | adjacentDay | userConfirm |
|
||||
| low | * | manualOnly |
|
||||
| * | outOfRange | manualOnly |
|
||||
|
||||
### Deterministic Game Matching
|
||||
|
||||
**File**: `SportsTime/Core/Services/GameMatcher.swift`
|
||||
|
||||
```swift
|
||||
enum GameMatchResult {
|
||||
case singleMatch(HistoricalGameResult) // Auto-select
|
||||
case multipleMatches([HistoricalGameResult]) // User selects
|
||||
case noMatches(reason: NoMatchReason) // Manual entry
|
||||
}
|
||||
|
||||
enum NoMatchReason {
|
||||
case noStadiumNearby
|
||||
case noGamesOnDate
|
||||
case metadataMissing
|
||||
}
|
||||
|
||||
actor GameMatcher {
|
||||
/// Match photo metadata to a game
|
||||
func matchGame(
|
||||
metadata: PhotoMetadata,
|
||||
sport: Sport?
|
||||
) async -> GameMatchResult
|
||||
}
|
||||
```
|
||||
|
||||
**Resolution rules** (deterministic, never guess):
|
||||
|
||||
| Scenario | Action |
|
||||
|----------|--------|
|
||||
| 1 game at 1 stadium on date | Auto-select |
|
||||
| Multiple games same stadium (doubleheader) | User selects |
|
||||
| Multiple stadiums nearby | User selects stadium first |
|
||||
| 0 games found | Manual entry allowed |
|
||||
| Missing GPS | Manual entry required |
|
||||
| Missing date | Manual entry required |
|
||||
|
||||
---
|
||||
|
||||
## Score Resolution Strategy
|
||||
|
||||
**Layered approach using FREE data sources only**.
|
||||
|
||||
### Tier 1: App Schedule Data
|
||||
|
||||
Check if game exists in app's schedule database (current season + cached historical).
|
||||
|
||||
```swift
|
||||
// In HistoricalGameService
|
||||
func resolveFromAppData(query: HistoricalGameQuery) async -> HistoricalGameResult?
|
||||
```
|
||||
|
||||
### Tier 2: Free Sports APIs
|
||||
|
||||
**File**: `SportsTime/Core/Services/FreeScoreAPI.swift`
|
||||
|
||||
```swift
|
||||
enum ProviderReliability {
|
||||
case official // MLB Stats, NHL Stats - stable, documented
|
||||
case unofficial // ESPN API - works but may break
|
||||
case scraped // Sports-Reference - HTML parsing, fragile
|
||||
}
|
||||
|
||||
protocol ScoreAPIProvider {
|
||||
var name: String { get }
|
||||
var supportedSports: Set<Sport> { get }
|
||||
var rateLimit: TimeInterval { get }
|
||||
var reliability: ProviderReliability { get }
|
||||
|
||||
func fetchGame(query: HistoricalGameQuery) async throws -> ResolvedGame?
|
||||
}
|
||||
|
||||
actor FreeScoreAPI {
|
||||
private let providers: [ScoreAPIProvider]
|
||||
private var disabledProviders: [String: Date] = [:] // provider → disabled until
|
||||
private var failureCounts: [String: Int] = [:]
|
||||
|
||||
/// Try each provider in order: official > unofficial > scraped
|
||||
func resolveScore(query: HistoricalGameQuery) async -> ScoreResolutionResult
|
||||
|
||||
/// Auto-disable logic for unreliable providers
|
||||
private func recordFailure(for provider: ScoreAPIProvider)
|
||||
private func isDisabled(_ provider: ScoreAPIProvider) -> Bool
|
||||
}
|
||||
```
|
||||
|
||||
**Provider failure handling**:
|
||||
- Official providers: Retry on failure, never auto-disable
|
||||
- Unofficial providers: After 3 failures in 1 hour → disable for 24h
|
||||
- Scraped providers: After 2 failures in 1 hour → disable for 24h
|
||||
|
||||
**Provider priority order** (always prefer stability):
|
||||
1. Official APIs (MLB Stats, NHL Stats)
|
||||
2. Unofficial APIs (ESPN, NBA Stats)
|
||||
3. Scraped sources (Sports-Reference)
|
||||
|
||||
**Free API providers** (implement ScoreAPIProvider):
|
||||
|
||||
| Provider | Sports | Rate Limit | Reliability | Notes |
|
||||
|----------|--------|------------|-------------|-------|
|
||||
| MLB Stats API | MLB | 10 req/sec | official | Documented, stable |
|
||||
| NHL Stats API | NHL | 5 req/sec | official | Documented, stable |
|
||||
| NBA Stats API | NBA | 2 req/sec | unofficial | Requires headers, may break |
|
||||
| ESPN API | All | 1 req/sec | unofficial | Undocumented, good depth |
|
||||
|
||||
### Tier 3: Reference Site Scraping
|
||||
|
||||
**File**: `SportsTime/Core/Services/ReferenceScrapingService.swift`
|
||||
|
||||
```swift
|
||||
actor ReferenceScrapingService {
|
||||
/// Scrape game data from sports-reference sites
|
||||
func scrapeGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult?
|
||||
}
|
||||
```
|
||||
|
||||
**Sources**:
|
||||
- Baseball-Reference.com (MLB, 1876-present)
|
||||
- Basketball-Reference.com (NBA, 1946-present)
|
||||
- Hockey-Reference.com (NHL, 1917-present)
|
||||
- Pro-Football-Reference.com (NFL, 1920-present)
|
||||
|
||||
**Scraping rules**:
|
||||
- **Cache aggressively**: Historical scores never change
|
||||
- **Rate limit**: Max 1 request per 3 seconds per domain
|
||||
- **Respect robots.txt**: Check before scraping
|
||||
- **User-Agent**: Identify as SportsTime app
|
||||
|
||||
### Tier 4: User Confirmation
|
||||
|
||||
If all automated resolution fails:
|
||||
|
||||
```swift
|
||||
struct UserConfirmedGame {
|
||||
let homeTeam: String
|
||||
let awayTeam: String
|
||||
let finalScore: String? // Optional - user may not remember
|
||||
let isUserConfirmed: Bool // Always true for this tier
|
||||
}
|
||||
```
|
||||
|
||||
**UI flow**:
|
||||
1. Show "We couldn't find this game automatically"
|
||||
2. Ask for home team, away team (autocomplete from known teams)
|
||||
3. Optionally ask for score
|
||||
4. Mark entry as `source = "user_confirmed"`
|
||||
|
||||
---
|
||||
|
||||
## Score Resolution Result
|
||||
|
||||
```swift
|
||||
enum ScoreResolutionResult {
|
||||
case resolved(HistoricalGameResult, source: ResolutionSource)
|
||||
case pending // Background retry queued
|
||||
case requiresUserInput // All tiers failed
|
||||
}
|
||||
|
||||
enum ResolutionSource: String, Codable {
|
||||
case appData = "app"
|
||||
case freeAPI = "api"
|
||||
case scraped = "scraped"
|
||||
case userConfirmed = "user"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Photo Storage & Sync
|
||||
|
||||
**File**: `SportsTime/Core/Services/VisitPhotoService.swift`
|
||||
|
||||
- **Thumbnails**: Stored locally as Data in SwiftData (fast loading)
|
||||
- **Full images**: CloudKit CKAsset in user's private database
|
||||
- **Upload**: Background task, retry on failure
|
||||
- **Download**: On-demand with local caching
|
||||
|
||||
```swift
|
||||
actor VisitPhotoService {
|
||||
func addPhoto(to visit: StadiumVisit, image: UIImage, caption: String?) async throws -> VisitPhotoMetadata
|
||||
func fetchFullImage(for metadata: VisitPhotoMetadata) async throws -> UIImage
|
||||
func deletePhoto(_ metadata: VisitPhotoMetadata) async throws
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Caching & Rate Limiting
|
||||
|
||||
**File**: `SportsTime/Core/Services/ScoreResolutionCache.swift`
|
||||
|
||||
```swift
|
||||
@Model
|
||||
final class CachedGameScore {
|
||||
@Attribute(.unique) var cacheKey: String // "MLB_2010-06-15_SFG_LAD"
|
||||
var homeTeam: String
|
||||
var awayTeam: String
|
||||
var homeScore: Int
|
||||
var awayScore: Int
|
||||
var source: String
|
||||
var fetchedAt: Date
|
||||
var expiresAt: Date? // nil = never expires (historical data)
|
||||
}
|
||||
|
||||
actor ScoreResolutionCache {
|
||||
func getCached(query: HistoricalGameQuery) async -> HistoricalGameResult?
|
||||
func cache(result: HistoricalGameResult, query: HistoricalGameQuery) async
|
||||
}
|
||||
```
|
||||
|
||||
**Cache policy**:
|
||||
- Historical games (>30 days old): Cache forever
|
||||
- Recent games: Cache 24 hours (scores might update)
|
||||
- Failed lookups: Cache 7 days (avoid repeated failures)
|
||||
|
||||
**Rate limiter**:
|
||||
|
||||
```swift
|
||||
actor RateLimiter {
|
||||
private var lastRequestTimes: [String: Date] = [:]
|
||||
|
||||
func waitIfNeeded(for provider: String, interval: TimeInterval) async
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sharing & Export
|
||||
|
||||
**File**: `SportsTime/Export/Services/ProgressCardGenerator.swift`
|
||||
|
||||
```swift
|
||||
@MainActor
|
||||
final class ProgressCardGenerator {
|
||||
func generateCard(progress: LeagueProgress, options: ProgressCardOptions) async throws -> UIImage
|
||||
func generateProgressMap(visited: [Stadium], remaining: [Stadium]) async throws -> UIImage
|
||||
}
|
||||
```
|
||||
|
||||
**Card contents**:
|
||||
- League logo
|
||||
- Progress ring (X/30)
|
||||
- Stats row
|
||||
- Optional username
|
||||
- Mini map snapshot
|
||||
- App branding footer
|
||||
|
||||
**Export size**: 1080x1920 (Instagram story)
|
||||
|
||||
---
|
||||
|
||||
## Files to Create
|
||||
|
||||
### Core Models
|
||||
- `SportsTime/Core/Models/Local/StadiumProgress.swift` - SwiftData models
|
||||
- `SportsTime/Core/Models/Domain/Progress.swift` - Domain structs
|
||||
- `SportsTime/Core/Models/Domain/Division.swift` - League structure
|
||||
- `SportsTime/Core/Models/Domain/AchievementDefinitions.swift` - Badge registry
|
||||
- `SportsTime/Core/Models/Domain/CanonicalGameKey.swift` - Stable game identity + ResolvedGame
|
||||
|
||||
### Services
|
||||
- `SportsTime/Core/Services/StadiumIdentityService.swift` - Canonical ID resolution
|
||||
- `SportsTime/Core/Services/AchievementEngine.swift` - Achievement computation
|
||||
- `SportsTime/Core/Services/HistoricalGameService.swift` - Historical lookup orchestrator
|
||||
- `SportsTime/Core/Services/VisitPhotoService.swift` - CloudKit photo sync
|
||||
|
||||
### Photo Import Pipeline
|
||||
- `SportsTime/Core/Services/PhotoMetadataExtractor.swift` - EXIF extraction from PHAsset
|
||||
- `SportsTime/Core/Services/StadiumProximityMatcher.swift` - GPS-to-stadium matching
|
||||
- `SportsTime/Core/Services/GameMatcher.swift` - Deterministic game resolution
|
||||
|
||||
### Score Resolution
|
||||
- `SportsTime/Core/Services/FreeScoreAPI.swift` - Multi-provider API facade
|
||||
- `SportsTime/Core/Services/ScoreAPIProviders/ESPNProvider.swift` - ESPN unofficial API
|
||||
- `SportsTime/Core/Services/ScoreAPIProviders/MLBStatsProvider.swift` - MLB Stats API
|
||||
- `SportsTime/Core/Services/ScoreAPIProviders/NHLStatsProvider.swift` - NHL Stats API
|
||||
- `SportsTime/Core/Services/ScoreAPIProviders/NBAStatsProvider.swift` - NBA Stats API
|
||||
- `SportsTime/Core/Services/ReferenceScrapingService.swift` - Sports-Reference fallback
|
||||
- `SportsTime/Core/Services/ScoreResolutionCache.swift` - SwiftData cache for scores
|
||||
- `SportsTime/Core/Services/RateLimiter.swift` - Per-provider rate limiting
|
||||
|
||||
### Features
|
||||
- `SportsTime/Features/Progress/Views/ProgressTabView.swift` - Main tab
|
||||
- `SportsTime/Features/Progress/Views/ProgressMapView.swift` - Interactive map
|
||||
- `SportsTime/Features/Progress/Views/StadiumVisitSheet.swift` - Log visit (manual entry)
|
||||
- `SportsTime/Features/Progress/Views/PhotoImportView.swift` - Photo picker + metadata extraction
|
||||
- `SportsTime/Features/Progress/Views/GameMatchConfirmationView.swift` - Disambiguate multiple matches
|
||||
- `SportsTime/Features/Progress/Views/VisitDetailView.swift` - View/edit visit
|
||||
- `SportsTime/Features/Progress/Views/AchievementsListView.swift` - Badge gallery
|
||||
- `SportsTime/Features/Progress/Views/ProgressShareView.swift` - Share preview
|
||||
- `SportsTime/Features/Progress/ViewModels/ProgressViewModel.swift` - Main state
|
||||
- `SportsTime/Features/Progress/ViewModels/PhotoImportViewModel.swift` - Photo import orchestration
|
||||
|
||||
### Export
|
||||
- `SportsTime/Export/Services/ProgressCardGenerator.swift` - Shareable cards
|
||||
|
||||
### Resources
|
||||
- `Resources/stadium_identities.json` - Canonical stadium mapping
|
||||
- `Resources/league_structure.json` - Division/conference data
|
||||
- `Resources/historical_game_index_mlb.json` - Game index (date → stadium → teams, no scores)
|
||||
- `Resources/historical_game_index_nba.json` - Game index (~500KB)
|
||||
- `Resources/historical_game_index_nhl.json` - Game index (~500KB)
|
||||
- `Resources/team_aliases.json` - Team name normalization (old names → canonical IDs)
|
||||
|
||||
## Files to Modify
|
||||
|
||||
- `SportsTime/SportsTimeApp.swift` - Add new models to SwiftData schema
|
||||
- `SportsTime/Features/Home/Views/HomeView.swift` - Add Progress tab
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
### Phase 1: Data Foundation
|
||||
1. Create SwiftData models (StadiumVisit, Achievement, VisitPhotoMetadata, CachedGameScore)
|
||||
2. Create Division.swift with LeagueStructure
|
||||
3. Build StadiumIdentityService with bundled JSON
|
||||
4. Create CanonicalGameKey and ResolvedGame
|
||||
5. Create RateLimiter actor
|
||||
6. Update SportsTimeApp.swift schema
|
||||
|
||||
### Phase 2: Core Progress UI
|
||||
1. Create ProgressViewModel
|
||||
2. Build ProgressTabView with league selector
|
||||
3. Implement ProgressMapView with MKMapView
|
||||
4. Add Progress tab to HomeView
|
||||
|
||||
### Phase 3: Manual Visit Logging
|
||||
1. Create StadiumVisitSheet for manual entry
|
||||
2. Implement auto-fill from trip games
|
||||
3. Build VisitDetailView for viewing/editing
|
||||
|
||||
### Phase 4: Photo Import Pipeline
|
||||
1. Build PhotoMetadataExtractor (EXIF from PHAsset)
|
||||
2. Create StadiumProximityMatcher (GPS → stadium)
|
||||
3. Implement GameMatcher (deterministic rules + confidence scoring)
|
||||
4. Build PhotoImportView with picker
|
||||
5. Create GameMatchConfirmationView for disambiguation
|
||||
6. Integrate into ProgressViewModel
|
||||
|
||||
### Phase 5: Score Resolution
|
||||
1. Create ScoreAPIProvider protocol with reliability
|
||||
2. Implement MLB Stats API provider (official)
|
||||
3. Implement NHL Stats API provider (official)
|
||||
4. Implement NBA Stats API provider (unofficial)
|
||||
5. Implement ESPN API provider (unofficial, fallback)
|
||||
6. Build ReferenceScrapingService (Tier 3, scraped)
|
||||
7. Create ScoreResolutionCache
|
||||
8. Wire up FreeScoreAPI orchestrator with auto-disable
|
||||
|
||||
### Phase 6: Achievements
|
||||
1. Create AchievementDefinitions registry
|
||||
2. Build AchievementEngine with computation logic
|
||||
3. Create achievement UI components
|
||||
4. Wire up achievement earned notifications
|
||||
|
||||
### Phase 7: Photos & Sharing
|
||||
1. Implement VisitPhotoService with CloudKit
|
||||
2. Build photo gallery UI
|
||||
3. Create ProgressCardGenerator
|
||||
4. Implement share sheet integration
|
||||
|
||||
---
|
||||
|
||||
## Failure Modes & Recovery
|
||||
|
||||
**Explicit handling for all failure scenarios — no silent failures.**
|
||||
|
||||
### Photo Import Failures
|
||||
|
||||
| Failure | Detection | Recovery |
|
||||
|---------|-----------|----------|
|
||||
| Missing GPS in photo | `PhotoMetadata.hasValidLocation == false` | Show "Location not found" → manual stadium selection |
|
||||
| Missing date in photo | `PhotoMetadata.hasValidDate == false` | Show "Date not found" → manual date picker |
|
||||
| Photo library access denied | `PHPhotoLibrary.authorizationStatus()` | Show settings deep link, explain why needed |
|
||||
| PHAsset fetch fails | `PHImageManager` error | Show error, allow retry or skip |
|
||||
|
||||
### Game Matching Failures
|
||||
|
||||
| Failure | Detection | Recovery |
|
||||
|---------|-----------|----------|
|
||||
| No stadium within 5km | `StadiumProximityMatcher` returns empty | Show "No stadium found nearby" → manual stadium picker |
|
||||
| No games on date | `GameMatcher` returns `.noMatches` | Show "No games found" → allow manual entry with team autocomplete |
|
||||
| Ambiguous match (multiple games) | `GameMatcher` returns `.multipleMatches` | Show picker: "Which game?" with team matchups |
|
||||
| Ambiguous stadium (multiple nearby) | Multiple stadiums in radius | Show picker: "Which stadium?" with distances |
|
||||
|
||||
### Score Resolution Failures
|
||||
|
||||
| Failure | Detection | Recovery |
|
||||
|---------|-----------|----------|
|
||||
| All API tiers fail | `ScoreResolutionResult.requiresUserInput` | Show "Score not found" → optional manual score entry |
|
||||
| Rate limited by provider | HTTP 429 | Queue for background retry, show "pending" state |
|
||||
| Network offline | URLError.notConnectedToInternet | Cache partial visit, retry score on reconnect |
|
||||
| Unofficial provider breaks | 3 failures in 1 hour | Auto-disable for 24h, use next tier |
|
||||
| Scraped provider breaks | 2 failures in 1 hour | Auto-disable for 24h, use next tier |
|
||||
|
||||
### Data Integrity Failures
|
||||
|
||||
| Failure | Detection | Recovery |
|
||||
|---------|-----------|----------|
|
||||
| CloudKit upload fails | CKError | Store locally with `uploadStatus = "failed"`, retry queue |
|
||||
| SwiftData save fails | ModelContext error | Show error, don't dismiss sheet, allow retry |
|
||||
| Corrupt cached score | JSON decode fails | Delete cache entry, refetch |
|
||||
| Duplicate visit detected | Same stadium + date | Warn user, allow anyway (doubleheader edge case) |
|
||||
|
||||
### User Data Marking
|
||||
|
||||
All user-provided data is explicitly marked:
|
||||
|
||||
```swift
|
||||
// In StadiumVisit
|
||||
var dataSource: DataSource
|
||||
|
||||
enum DataSource: String, Codable {
|
||||
case automatic // All data from photo + API
|
||||
case partialManual // Photo metadata + manual game selection
|
||||
case fullyManual // User entered everything
|
||||
case userCorrected // Was automatic, user edited
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases
|
||||
|
||||
| Scenario | Handling |
|
||||
|----------|----------|
|
||||
| Stadium renamed after visit | canonicalStadiumId stable, stadiumNameAtVisit frozen |
|
||||
| User visits same stadium twice | Both logged, unique stadiums counted once |
|
||||
| Visit without game (tour) | visitType = "tour", no game fields required |
|
||||
| Historical game not found | Manual description, source = "user_confirmed" |
|
||||
| Photo upload fails | uploadStatus = "failed", retry on next launch |
|
||||
| Achievement revoked | revokedAt set, not shown in earned list |
|
||||
| Team relocates mid-tracking | Old stadium still counts, new is separate |
|
||||
| Doubleheader same day | Both games shown, user selects correct one |
|
||||
| Photo from parking lot (1km away) | Medium confidence, still matches if only stadium nearby |
|
||||
| Multiple sports same stadium | Filter by sport if provided, else show all |
|
||||
| Timezone mismatch (night game shows wrong date) | Use ±1 day tolerance in matching |
|
||||
| User edits auto-resolved data | Mark as `dataSource = .userCorrected`, preserve original |
|
||||
| API returns different team name | Fuzzy match via team_aliases.json |
|
||||
| Score correction after caching | Cache key includes source version, can refresh |
|
||||
Reference in New Issue
Block a user