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"
|
HOST = "https://api.apple-cloudkit.com"
|
||||||
BATCH_SIZE = 200
|
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:
|
def deterministic_uuid(string: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -214,19 +248,55 @@ def import_data(ck, records, name, dry_run, verbose):
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
p = argparse.ArgumentParser(description='Import JSON to CloudKit')
|
p = argparse.ArgumentParser(description='Import JSON to CloudKit')
|
||||||
p.add_argument('--key-id', default=os.environ.get('CLOUDKIT_KEY_ID'))
|
p.add_argument('--key-id', default=DEFAULT_KEY_ID)
|
||||||
p.add_argument('--key-file', default=os.environ.get('CLOUDKIT_KEY_FILE'))
|
p.add_argument('--key-file', default=DEFAULT_KEY_FILE)
|
||||||
p.add_argument('--container', default=CONTAINER)
|
p.add_argument('--container', default=CONTAINER)
|
||||||
p.add_argument('--env', choices=['development', 'production'], default='development')
|
p.add_argument('--env', choices=['development', 'production'], default='development')
|
||||||
p.add_argument('--data-dir', default='./data')
|
p.add_argument('--data-dir', default='./data')
|
||||||
p.add_argument('--stadiums-only', action='store_true')
|
p.add_argument('--stadiums-only', action='store_true')
|
||||||
p.add_argument('--games-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-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('--delete-only', action='store_true', help='Only delete records, do not import')
|
||||||
p.add_argument('--dry-run', action='store_true')
|
p.add_argument('--dry-run', action='store_true')
|
||||||
p.add_argument('--verbose', '-v', 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()
|
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"\n{'='*50}")
|
||||||
print(f"CloudKit Import {'(DRY RUN)' if args.dry_run else ''}")
|
print(f"CloudKit Import {'(DRY RUN)' if args.dry_run else ''}")
|
||||||
print(f"{'='*50}")
|
print(f"{'='*50}")
|
||||||
@@ -236,14 +306,16 @@ def main():
|
|||||||
data_dir = Path(args.data_dir)
|
data_dir = Path(args.data_dir)
|
||||||
stadiums = json.load(open(data_dir / 'stadiums.json'))
|
stadiums = json.load(open(data_dir / 'stadiums.json'))
|
||||||
games = json.load(open(data_dir / 'games.json')) if (data_dir / 'games.json').exists() else []
|
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
|
ck = None
|
||||||
if not args.dry_run:
|
if not args.dry_run:
|
||||||
if not HAS_CRYPTO:
|
if not HAS_CRYPTO:
|
||||||
sys.exit("Error: pip install cryptography")
|
sys.exit("Error: pip install cryptography")
|
||||||
if not args.key_id or not args.key_file:
|
if not os.path.exists(args.key_file):
|
||||||
sys.exit("Error: --key-id and --key-file required (or use --dry-run)")
|
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)
|
ck = CloudKit(args.key_id, open(args.key_file, 'rb').read(), args.container, args.env)
|
||||||
|
|
||||||
# Handle deletion
|
# Handle deletion
|
||||||
@@ -252,8 +324,8 @@ def main():
|
|||||||
sys.exit("Error: --key-id and --key-file required for deletion")
|
sys.exit("Error: --key-id and --key-file required for deletion")
|
||||||
|
|
||||||
print("--- Deleting Existing Records ---")
|
print("--- Deleting Existing Records ---")
|
||||||
# Delete in order: Games first (has references), then Teams, then Stadiums
|
# Delete in order: dependent records first, then base records
|
||||||
for record_type in ['Game', 'Team', 'Stadium']:
|
for record_type in ['Game', 'TeamAlias', 'Team', 'LeagueStructure', 'Stadium']:
|
||||||
print(f" Deleting {record_type} records...")
|
print(f" Deleting {record_type} records...")
|
||||||
deleted = ck.delete_all(record_type, verbose=args.verbose)
|
deleted = ck.delete_all(record_type, verbose=args.verbose)
|
||||||
print(f" Deleted {deleted} {record_type} records")
|
print(f" Deleted {deleted} {record_type} records")
|
||||||
@@ -264,14 +336,21 @@ def main():
|
|||||||
print()
|
print()
|
||||||
return
|
return
|
||||||
|
|
||||||
stats = {'stadiums': 0, 'teams': 0, 'games': 0}
|
stats = {'stadiums': 0, 'teams': 0, 'games': 0, 'league_structures': 0, 'team_aliases': 0}
|
||||||
team_map = {}
|
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)
|
# Build stadium UUID lookup (stadium string ID -> UUID)
|
||||||
stadium_uuid_map = {s['id']: deterministic_uuid(s['id']) for s in stadiums}
|
stadium_uuid_map = {s['id']: deterministic_uuid(s['id']) for s in stadiums}
|
||||||
|
|
||||||
# Import stadiums & teams
|
# Import stadiums & teams
|
||||||
if not args.games_only:
|
if import_stadiums:
|
||||||
print("--- Stadiums ---")
|
print("--- Stadiums ---")
|
||||||
recs = [{
|
recs = [{
|
||||||
'recordType': 'Stadium', 'recordName': stadium_uuid_map[s['id']],
|
'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)
|
stats['teams'] = import_data(ck, recs, 'teams', args.dry_run, args.verbose)
|
||||||
|
|
||||||
# Import games
|
# Import games
|
||||||
if not args.stadiums_only and games:
|
if import_games and games:
|
||||||
# Rebuild team_map if only importing games (--games-only flag)
|
# Rebuild team_map if only importing games (--games-only flag)
|
||||||
if not team_map:
|
if not team_map:
|
||||||
for s in stadiums:
|
for s in stadiums:
|
||||||
@@ -388,8 +467,63 @@ def main():
|
|||||||
|
|
||||||
stats['games'] = import_data(ck, recs, 'games', args.dry_run, args.verbose)
|
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"\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:
|
if args.dry_run:
|
||||||
print("[DRY RUN - nothing imported]")
|
print("[DRY RUN - nothing imported]")
|
||||||
print()
|
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 stadium = "Stadium"
|
||||||
static let game = "Game"
|
static let game = "Game"
|
||||||
static let sport = "Sport"
|
static let sport = "Sport"
|
||||||
|
static let leagueStructure = "LeagueStructure"
|
||||||
|
static let teamAlias = "TeamAlias"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CKTeam
|
// MARK: - CKTeam
|
||||||
@@ -100,6 +102,7 @@ struct CKStadium {
|
|||||||
static let capacityKey = "capacity"
|
static let capacityKey = "capacity"
|
||||||
static let yearOpenedKey = "yearOpened"
|
static let yearOpenedKey = "yearOpened"
|
||||||
static let imageURLKey = "imageURL"
|
static let imageURLKey = "imageURL"
|
||||||
|
static let sportKey = "sport"
|
||||||
|
|
||||||
let record: CKRecord
|
let record: CKRecord
|
||||||
|
|
||||||
@@ -117,6 +120,7 @@ struct CKStadium {
|
|||||||
record[CKStadium.capacityKey] = stadium.capacity
|
record[CKStadium.capacityKey] = stadium.capacity
|
||||||
record[CKStadium.yearOpenedKey] = stadium.yearOpened
|
record[CKStadium.yearOpenedKey] = stadium.yearOpened
|
||||||
record[CKStadium.imageURLKey] = stadium.imageURL?.absoluteString
|
record[CKStadium.imageURLKey] = stadium.imageURL?.absoluteString
|
||||||
|
record[CKStadium.sportKey] = stadium.sport.rawValue
|
||||||
self.record = record
|
self.record = record
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +137,8 @@ struct CKStadium {
|
|||||||
let location = record[CKStadium.locationKey] as? CLLocation
|
let location = record[CKStadium.locationKey] as? CLLocation
|
||||||
let capacity = record[CKStadium.capacityKey] as? Int ?? 0
|
let capacity = record[CKStadium.capacityKey] as? Int ?? 0
|
||||||
let imageURL = (record[CKStadium.imageURLKey] as? String).flatMap { URL(string: $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(
|
return Stadium(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -142,6 +148,7 @@ struct CKStadium {
|
|||||||
latitude: location?.coordinate.latitude ?? 0,
|
latitude: location?.coordinate.latitude ?? 0,
|
||||||
longitude: location?.coordinate.longitude ?? 0,
|
longitude: location?.coordinate.longitude ?? 0,
|
||||||
capacity: capacity,
|
capacity: capacity,
|
||||||
|
sport: sport,
|
||||||
yearOpened: record[CKStadium.yearOpenedKey] as? Int,
|
yearOpened: record[CKStadium.yearOpenedKey] as? Int,
|
||||||
imageURL: imageURL
|
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 latitude: Double
|
||||||
let longitude: Double
|
let longitude: Double
|
||||||
let capacity: Int
|
let capacity: Int
|
||||||
|
let sport: Sport
|
||||||
let yearOpened: Int?
|
let yearOpened: Int?
|
||||||
let imageURL: URL?
|
let imageURL: URL?
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ struct Stadium: Identifiable, Codable, Hashable {
|
|||||||
latitude: Double,
|
latitude: Double,
|
||||||
longitude: Double,
|
longitude: Double,
|
||||||
capacity: Int,
|
capacity: Int,
|
||||||
|
sport: Sport,
|
||||||
yearOpened: Int? = nil,
|
yearOpened: Int? = nil,
|
||||||
imageURL: URL? = nil
|
imageURL: URL? = nil
|
||||||
) {
|
) {
|
||||||
@@ -35,6 +37,7 @@ struct Stadium: Identifiable, Codable, Hashable {
|
|||||||
self.latitude = latitude
|
self.latitude = latitude
|
||||||
self.longitude = longitude
|
self.longitude = longitude
|
||||||
self.capacity = capacity
|
self.capacity = capacity
|
||||||
|
self.sport = sport
|
||||||
self.yearOpened = yearOpened
|
self.yearOpened = yearOpened
|
||||||
self.imageURL = imageURL
|
self.imageURL = imageURL
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,7 +58,15 @@ struct Trip: Identifiable, Codable, Hashable {
|
|||||||
return totalDrivingHours / Double(tripDuration)
|
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 uniqueSports: Set<Sport> { preferences.sports }
|
||||||
var startDate: Date { stops.first?.arrivalDate ?? preferences.startDate }
|
var startDate: Date { stops.first?.arrivalDate ?? preferences.startDate }
|
||||||
var endDate: Date { stops.last?.departureDate ?? preferences.endDate }
|
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)
|
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
|
// MARK: - Sync Status
|
||||||
|
|
||||||
func checkAccountStatus() async -> CKAccountStatus {
|
func checkAccountStatus() async -> CKAccountStatus {
|
||||||
@@ -199,7 +280,7 @@ actor CloudKitService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Subscription (for schedule updates)
|
// MARK: - Subscriptions
|
||||||
|
|
||||||
func subscribeToScheduleUpdates() async throws {
|
func subscribeToScheduleUpdates() async throws {
|
||||||
let subscription = CKQuerySubscription(
|
let subscription = CKQuerySubscription(
|
||||||
@@ -215,4 +296,41 @@ actor CloudKitService {
|
|||||||
|
|
||||||
try await publicDatabase.save(subscription)
|
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,
|
latitude: json.latitude,
|
||||||
longitude: json.longitude,
|
longitude: json.longitude,
|
||||||
capacity: json.capacity,
|
capacity: json.capacity,
|
||||||
|
sport: parseSport(json.sport),
|
||||||
yearOpened: json.year_opened
|
yearOpened: json.year_opened
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,13 +265,17 @@ final class SuggestedTripsGenerator {
|
|||||||
// Build richGames dictionary
|
// Build richGames dictionary
|
||||||
let richGames = buildRichGames(from: selectedGames, teams: teams, stadiums: stadiums)
|
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(
|
return SuggestedTrip(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
region: region,
|
region: region,
|
||||||
isSingleSport: singleSport,
|
isSingleSport: actualSports.count == 1,
|
||||||
trip: trip,
|
trip: trip,
|
||||||
richGames: richGames,
|
richGames: richGames,
|
||||||
sports: sports
|
sports: actualSports.isEmpty ? sports : actualSports
|
||||||
)
|
)
|
||||||
|
|
||||||
case .failure:
|
case .failure:
|
||||||
@@ -339,6 +343,10 @@ final class SuggestedTripsGenerator {
|
|||||||
|
|
||||||
guard selectedGames.count >= 4 else { return nil }
|
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
|
// Calculate trip dates
|
||||||
guard let firstGame = selectedGames.first,
|
guard let firstGame = selectedGames.first,
|
||||||
let lastGame = selectedGames.last else { return nil }
|
let lastGame = selectedGames.last else { return nil }
|
||||||
@@ -377,13 +385,29 @@ final class SuggestedTripsGenerator {
|
|||||||
// Build richGames dictionary
|
// Build richGames dictionary
|
||||||
let richGames = buildRichGames(from: selectedGames, teams: teams, stadiums: stadiums)
|
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(
|
return SuggestedTrip(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
region: .crossCountry,
|
region: .crossCountry,
|
||||||
isSingleSport: sports.count == 1,
|
isSingleSport: actualSports.count == 1,
|
||||||
trip: trip,
|
trip: trip,
|
||||||
richGames: richGames,
|
richGames: richGames,
|
||||||
sports: sports
|
sports: actualSports.isEmpty ? sports : actualSports
|
||||||
)
|
)
|
||||||
|
|
||||||
case .failure:
|
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
|
// MARK: - Pulsing Dot
|
||||||
|
|
||||||
struct PulsingDot: View {
|
struct PulsingDot: View {
|
||||||
@@ -188,10 +257,8 @@ struct PlanningProgressView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 24) {
|
VStack(spacing: 24) {
|
||||||
// Simple spinner
|
// Themed spinner
|
||||||
ProgressView()
|
ThemedSpinner(size: 56, lineWidth: 5)
|
||||||
.scaleEffect(1.5)
|
|
||||||
.tint(Theme.warmOrange)
|
|
||||||
|
|
||||||
// Current step text
|
// Current step text
|
||||||
Text(steps[currentStep])
|
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
|
// 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") {
|
#Preview("Animated Components") {
|
||||||
VStack(spacing: 40) {
|
VStack(spacing: 40) {
|
||||||
AnimatedRouteGraphic()
|
AnimatedRouteGraphic()
|
||||||
@@ -306,3 +461,25 @@ struct EmptyStateView: View {
|
|||||||
.padding()
|
.padding()
|
||||||
.themedBackground()
|
.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 selectedTab = 0
|
||||||
@State private var suggestedTripsGenerator = SuggestedTripsGenerator()
|
@State private var suggestedTripsGenerator = SuggestedTripsGenerator()
|
||||||
@State private var selectedSuggestedTrip: SuggestedTrip?
|
@State private var selectedSuggestedTrip: SuggestedTrip?
|
||||||
|
@State private var tripCreationViewModel = TripCreationViewModel()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
TabView(selection: $selectedTab) {
|
TabView(selection: $selectedTab) {
|
||||||
@@ -83,6 +84,15 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
.tag(2)
|
.tag(2)
|
||||||
|
|
||||||
|
// Progress Tab
|
||||||
|
NavigationStack {
|
||||||
|
ProgressTabView()
|
||||||
|
}
|
||||||
|
.tabItem {
|
||||||
|
Label("Progress", systemImage: "chart.bar.fill")
|
||||||
|
}
|
||||||
|
.tag(3)
|
||||||
|
|
||||||
// Settings Tab
|
// Settings Tab
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
SettingsView()
|
SettingsView()
|
||||||
@@ -90,11 +100,11 @@ struct HomeView: View {
|
|||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Settings", systemImage: "gear")
|
Label("Settings", systemImage: "gear")
|
||||||
}
|
}
|
||||||
.tag(3)
|
.tag(4)
|
||||||
}
|
}
|
||||||
.tint(Theme.warmOrange)
|
.tint(Theme.warmOrange)
|
||||||
.sheet(isPresented: $showNewTrip) {
|
.sheet(isPresented: $showNewTrip) {
|
||||||
TripCreationView(initialSport: selectedSport)
|
TripCreationView(viewModel: tripCreationViewModel, initialSport: selectedSport)
|
||||||
}
|
}
|
||||||
.onChange(of: showNewTrip) { _, isShowing in
|
.onChange(of: showNewTrip) { _, isShowing in
|
||||||
if !isShowing {
|
if !isShowing {
|
||||||
@@ -110,6 +120,7 @@ struct HomeView: View {
|
|||||||
NavigationStack {
|
NavigationStack {
|
||||||
TripDetailView(trip: suggestedTrip.trip, games: suggestedTrip.richGames)
|
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
|
import SwiftUI
|
||||||
|
|
||||||
struct ScheduleListView: View {
|
struct ScheduleListView: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@State private var viewModel = ScheduleViewModel()
|
@State private var viewModel = ScheduleViewModel()
|
||||||
@State private var showDatePicker = false
|
@State private var showDatePicker = false
|
||||||
|
|
||||||
@@ -97,9 +98,11 @@ struct ScheduleListView: View {
|
|||||||
Text(formatSectionDate(dateGroup.date))
|
Text(formatSectionDate(dateGroup.date))
|
||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.listStyle(.plain)
|
.listStyle(.plain)
|
||||||
|
.scrollContentBackground(.hidden)
|
||||||
.refreshable {
|
.refreshable {
|
||||||
await viewModel.loadGames()
|
await viewModel.loadGames()
|
||||||
}
|
}
|
||||||
@@ -128,8 +131,7 @@ struct ScheduleListView: View {
|
|||||||
|
|
||||||
private var loadingView: some View {
|
private var loadingView: some View {
|
||||||
VStack(spacing: 16) {
|
VStack(spacing: 16) {
|
||||||
ProgressView()
|
ThemedSpinner(size: 44)
|
||||||
.scaleEffect(1.5)
|
|
||||||
Text("Loading schedule...")
|
Text("Loading schedule...")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SettingsView: View {
|
struct SettingsView: View {
|
||||||
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@State private var viewModel = SettingsViewModel()
|
@State private var viewModel = SettingsViewModel()
|
||||||
@State private var showResetConfirmation = false
|
@State private var showResetConfirmation = false
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ struct SettingsView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("Choose a color scheme for the app.")
|
Text("Choose a color scheme for the app.")
|
||||||
}
|
}
|
||||||
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Sports Section
|
// MARK: - Sports Section
|
||||||
@@ -115,6 +117,7 @@ struct SettingsView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("Selected sports will be shown by default in schedules and trip planning.")
|
Text("Selected sports will be shown by default in schedules and trip planning.")
|
||||||
}
|
}
|
||||||
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Travel Section
|
// MARK: - Travel Section
|
||||||
@@ -159,6 +162,7 @@ struct SettingsView: View {
|
|||||||
} footer: {
|
} footer: {
|
||||||
Text("Trips will be optimized to keep daily driving within this limit.")
|
Text("Trips will be optimized to keep daily driving within this limit.")
|
||||||
}
|
}
|
||||||
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Data Section
|
// MARK: - Data Section
|
||||||
@@ -176,7 +180,7 @@ struct SettingsView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
if viewModel.isSyncing {
|
if viewModel.isSyncing {
|
||||||
ProgressView()
|
ThemedSpinnerCompact(size: 18)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,6 +213,7 @@ struct SettingsView: View {
|
|||||||
Text("Schedule data is synced from CloudKit.")
|
Text("Schedule data is synced from CloudKit.")
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - About Section
|
// MARK: - About Section
|
||||||
@@ -236,6 +241,7 @@ struct SettingsView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("About")
|
Text("About")
|
||||||
}
|
}
|
||||||
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Reset Section
|
// MARK: - Reset Section
|
||||||
@@ -248,6 +254,7 @@ struct SettingsView: View {
|
|||||||
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
|
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helpers
|
// MARK: - Helpers
|
||||||
|
|||||||
@@ -348,6 +348,10 @@ final class TripCreationViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deselectAllGames() {
|
||||||
|
mustSeeGameIds.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
func switchPlanningMode(_ mode: PlanningMode) {
|
func switchPlanningMode(_ mode: PlanningMode) {
|
||||||
planningMode = mode
|
planningMode = mode
|
||||||
// Clear mode-specific selections when switching
|
// Clear mode-specific selections when switching
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ struct TripCreationView: View {
|
|||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
|
@Bindable var viewModel: TripCreationViewModel
|
||||||
let initialSport: Sport?
|
let initialSport: Sport?
|
||||||
|
|
||||||
@State private var viewModel = TripCreationViewModel()
|
init(viewModel: TripCreationViewModel, initialSport: Sport? = nil) {
|
||||||
|
self.viewModel = viewModel
|
||||||
init(initialSport: Sport? = nil) {
|
|
||||||
self.initialSport = initialSport
|
self.initialSport = initialSport
|
||||||
}
|
}
|
||||||
@State private var showGamePicker = false
|
@State private var showGamePicker = false
|
||||||
@@ -25,6 +25,16 @@ struct TripCreationView: View {
|
|||||||
@State private var completedTrip: Trip?
|
@State private var completedTrip: Trip?
|
||||||
@State private var tripOptions: [ItineraryOption] = []
|
@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 {
|
enum CityInputType {
|
||||||
case mustStop
|
case mustStop
|
||||||
case preferred
|
case preferred
|
||||||
@@ -214,35 +224,192 @@ struct TripCreationView: View {
|
|||||||
|
|
||||||
private var locationSection: some View {
|
private var locationSection: some View {
|
||||||
ThemedSection(title: "Locations") {
|
ThemedSection(title: "Locations") {
|
||||||
ThemedTextField(
|
// Start Location with suggestions
|
||||||
label: "Start Location",
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
placeholder: "Where are you starting from?",
|
ThemedTextField(
|
||||||
text: $viewModel.startLocationText,
|
label: "Start Location",
|
||||||
icon: "location.circle.fill"
|
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(
|
// Suggestions for start location
|
||||||
label: "End Location",
|
if !startLocationSuggestions.isEmpty {
|
||||||
placeholder: "Where do you want to end up?",
|
locationSuggestionsList(
|
||||||
text: $viewModel.endLocationText,
|
suggestions: startLocationSuggestions,
|
||||||
icon: "mappin.circle.fill"
|
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 {
|
private var gameBrowserSection: some View {
|
||||||
ThemedSection(title: "Select Games") {
|
ThemedSection(title: "Select Games") {
|
||||||
if viewModel.isLoadingGames || viewModel.availableGames.isEmpty {
|
if viewModel.isLoadingGames || viewModel.availableGames.isEmpty {
|
||||||
HStack(spacing: Theme.Spacing.sm) {
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
ProgressView()
|
ThemedSpinnerCompact(size: 20)
|
||||||
.tint(Theme.warmOrange)
|
|
||||||
Text("Loading games...")
|
Text("Loading games...")
|
||||||
.font(.system(size: Theme.FontSize.body))
|
.font(.system(size: Theme.FontSize.body))
|
||||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .center)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
.padding(.vertical, Theme.Spacing.md)
|
.padding(.vertical, Theme.Spacing.md)
|
||||||
.task {
|
.task(id: viewModel.selectedSports) {
|
||||||
|
// Re-run when sports selection changes
|
||||||
if viewModel.availableGames.isEmpty {
|
if viewModel.availableGames.isEmpty {
|
||||||
await viewModel.loadGamesForBrowsing()
|
await viewModel.loadGamesForBrowsing()
|
||||||
}
|
}
|
||||||
@@ -290,6 +457,16 @@ struct TripCreationView: View {
|
|||||||
Text("\(viewModel.mustSeeGameIds.count) game(s) selected")
|
Text("\(viewModel.mustSeeGameIds.count) game(s) selected")
|
||||||
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
.font(.system(size: Theme.FontSize.body, weight: .medium))
|
||||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
.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
|
// Show selected games preview
|
||||||
@@ -927,8 +1104,7 @@ struct LocationSearchSheet: View {
|
|||||||
.textFieldStyle(.plain)
|
.textFieldStyle(.plain)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
if isSearching {
|
if isSearching {
|
||||||
ProgressView()
|
ThemedSpinnerCompact(size: 16)
|
||||||
.scaleEffect(0.8)
|
|
||||||
} else if !searchText.isEmpty {
|
} else if !searchText.isEmpty {
|
||||||
Button {
|
Button {
|
||||||
searchText = ""
|
searchText = ""
|
||||||
@@ -1260,8 +1436,7 @@ struct TripOptionCard: View {
|
|||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
} else if isLoadingDescription {
|
} else if isLoadingDescription {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
ProgressView()
|
ThemedSpinnerCompact(size: 12)
|
||||||
.scaleEffect(0.6)
|
|
||||||
Text("Generating...")
|
Text("Generating...")
|
||||||
.font(.system(size: 11))
|
.font(.system(size: 11))
|
||||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
@@ -1806,5 +1981,5 @@ struct SportSelectionChip: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#Preview {
|
#Preview {
|
||||||
TripCreationView()
|
TripCreationView(viewModel: TripCreationViewModel())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -198,8 +198,7 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
// Loading indicator
|
// Loading indicator
|
||||||
if isLoadingRoutes {
|
if isLoadingRoutes {
|
||||||
ProgressView()
|
ThemedSpinnerCompact(size: 24)
|
||||||
.tint(Theme.warmOrange)
|
|
||||||
.padding(.bottom, 40)
|
.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 {
|
struct SportsTimeApp: App {
|
||||||
var sharedModelContainer: ModelContainer = {
|
var sharedModelContainer: ModelContainer = {
|
||||||
let schema = Schema([
|
let schema = Schema([
|
||||||
|
// User data models
|
||||||
SavedTrip.self,
|
SavedTrip.self,
|
||||||
TripVote.self,
|
TripVote.self,
|
||||||
UserPreferences.self,
|
UserPreferences.self,
|
||||||
CachedSchedule.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(
|
let modelConfiguration = ModelConfiguration(
|
||||||
schema: schema,
|
schema: schema,
|
||||||
@@ -32,8 +46,100 @@ struct SportsTimeApp: App {
|
|||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
HomeView()
|
BootstrappedContentView(modelContainer: sharedModelContainer)
|
||||||
}
|
}
|
||||||
.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(),
|
id: UUID = UUID(),
|
||||||
city: String,
|
city: String,
|
||||||
latitude: Double,
|
latitude: Double,
|
||||||
longitude: Double
|
longitude: Double,
|
||||||
|
sport: Sport = .mlb
|
||||||
) -> Stadium {
|
) -> Stadium {
|
||||||
Stadium(
|
Stadium(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -30,7 +31,8 @@ struct ScenarioAPlannerSwiftTests {
|
|||||||
state: "ST",
|
state: "ST",
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
capacity: 40000
|
capacity: 40000,
|
||||||
|
sport: sport
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ struct ScenarioBPlannerTests {
|
|||||||
city: String,
|
city: String,
|
||||||
state: String,
|
state: String,
|
||||||
latitude: Double,
|
latitude: Double,
|
||||||
longitude: Double
|
longitude: Double,
|
||||||
|
sport: Sport = .mlb
|
||||||
) -> Stadium {
|
) -> Stadium {
|
||||||
Stadium(
|
Stadium(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -31,7 +32,8 @@ struct ScenarioBPlannerTests {
|
|||||||
state: state,
|
state: state,
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
capacity: 40000
|
capacity: 40000,
|
||||||
|
sport: sport
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ struct ScenarioCPlannerTests {
|
|||||||
city: String,
|
city: String,
|
||||||
state: String,
|
state: String,
|
||||||
latitude: Double,
|
latitude: Double,
|
||||||
longitude: Double
|
longitude: Double,
|
||||||
|
sport: Sport = .mlb
|
||||||
) -> Stadium {
|
) -> Stadium {
|
||||||
Stadium(
|
Stadium(
|
||||||
id: id,
|
id: id,
|
||||||
@@ -32,7 +33,8 @@ struct ScenarioCPlannerTests {
|
|||||||
state: state,
|
state: state,
|
||||||
latitude: latitude,
|
latitude: latitude,
|
||||||
longitude: longitude,
|
longitude: longitude,
|
||||||
capacity: 40000
|
capacity: 40000,
|
||||||
|
sport: sport
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ struct DayCardTests {
|
|||||||
state: "ST",
|
state: "ST",
|
||||||
latitude: 40.0,
|
latitude: 40.0,
|
||||||
longitude: -100.0,
|
longitude: -100.0,
|
||||||
capacity: 40000
|
capacity: 40000,
|
||||||
|
sport: game.sport
|
||||||
)
|
)
|
||||||
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
|
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)
|
/// Tests for handling duplicate game IDs without crashing (regression test for fatal error)
|
||||||
struct DuplicateGameIdTests {
|
struct DuplicateGameIdTests {
|
||||||
|
|
||||||
private func makeStadium() -> Stadium {
|
private func makeStadium(sport: Sport = .mlb) -> Stadium {
|
||||||
Stadium(
|
Stadium(
|
||||||
id: UUID(),
|
id: UUID(),
|
||||||
name: "Test Stadium",
|
name: "Test Stadium",
|
||||||
@@ -358,7 +359,8 @@ struct DuplicateGameIdTests {
|
|||||||
state: "TS",
|
state: "TS",
|
||||||
latitude: 40.0,
|
latitude: 40.0,
|
||||||
longitude: -100.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