diff --git a/flutter_client/lib/models/album.dart b/flutter_client/lib/models/album.dart new file mode 100644 index 00000000..a8e12800 --- /dev/null +++ b/flutter_client/lib/models/album.dart @@ -0,0 +1,40 @@ +// Mirrors internal/api/types.go AlbumRef and web/src/lib/api/types.ts. +// +// `cover_url` (NOT cover_art_url) and `duration_sec` (NOT duration_ms) match +// the server contract. `year` is omitempty on the server so we keep it +// nullable. CoverURL is non-null but may be "" — UI branches on isEmpty. +class AlbumRef { + const AlbumRef({ + required this.id, + required this.title, + required this.artistId, + this.sortTitle = '', + this.artistName = '', + this.year, + this.trackCount = 0, + this.durationSec = 0, + this.coverUrl = '', + }); + + final String id; + final String title; + final String sortTitle; + final String artistId; + final String artistName; + final int? year; + final int trackCount; + final int durationSec; + final String coverUrl; + + factory AlbumRef.fromJson(Map j) => AlbumRef( + id: j['id'] as String, + title: j['title'] as String, + sortTitle: j['sort_title'] as String? ?? '', + artistId: j['artist_id'] as String, + artistName: j['artist_name'] as String? ?? '', + year: (j['year'] as num?)?.toInt(), + trackCount: (j['track_count'] as num?)?.toInt() ?? 0, + durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0, + coverUrl: j['cover_url'] as String? ?? '', + ); +} diff --git a/flutter_client/lib/models/artist.dart b/flutter_client/lib/models/artist.dart new file mode 100644 index 00000000..48eae240 --- /dev/null +++ b/flutter_client/lib/models/artist.dart @@ -0,0 +1,30 @@ +// Mirrors internal/api/types.go ArtistRef and web/src/lib/api/types.ts. +// +// `cover_url` is the server's field name (NOT cover_art_url). Server emits +// "" when the artist has no representative album cover, so we keep it +// non-null here and let UI code branch on isEmpty. `sort_name` and +// `album_count` are exposed by the server contract; we accept their +// absence defensively (older fixtures, partial mocks). +class ArtistRef { + const ArtistRef({ + required this.id, + required this.name, + this.sortName = '', + this.albumCount = 0, + this.coverUrl = '', + }); + + final String id; + final String name; + final String sortName; + final int albumCount; + final String coverUrl; + + factory ArtistRef.fromJson(Map j) => ArtistRef( + id: j['id'] as String, + name: j['name'] as String, + sortName: j['sort_name'] as String? ?? '', + albumCount: (j['album_count'] as num?)?.toInt() ?? 0, + coverUrl: j['cover_url'] as String? ?? '', + ); +} diff --git a/flutter_client/lib/models/home_data.dart b/flutter_client/lib/models/home_data.dart new file mode 100644 index 00000000..1e9c5afc --- /dev/null +++ b/flutter_client/lib/models/home_data.dart @@ -0,0 +1,46 @@ +import 'album.dart'; +import 'artist.dart'; +import 'track.dart'; + +// Mirrors internal/api/types.go HomePayload and web/src/lib/api/types.ts. +// Section keys MUST stay in sync with the server's JSON tags: +// recently_added_albums / rediscover_albums / rediscover_artists / +// most_played_tracks / last_played_artists. +// +// Server contract guarantees all five slices are non-nil at JSON encode +// time (empty sections render as []), but we still tolerate missing keys +// so partial fixtures and offline-degraded responses can be parsed. +class HomeData { + const HomeData({ + required this.recentlyAddedAlbums, + required this.rediscoverAlbums, + required this.rediscoverArtists, + required this.mostPlayedTracks, + required this.lastPlayedArtists, + }); + + final List recentlyAddedAlbums; + final List rediscoverAlbums; + final List rediscoverArtists; + final List mostPlayedTracks; + final List lastPlayedArtists; + + factory HomeData.fromJson(Map j) => HomeData( + recentlyAddedAlbums: _list(j, 'recently_added_albums', AlbumRef.fromJson), + rediscoverAlbums: _list(j, 'rediscover_albums', AlbumRef.fromJson), + rediscoverArtists: _list(j, 'rediscover_artists', ArtistRef.fromJson), + mostPlayedTracks: _list(j, 'most_played_tracks', TrackRef.fromJson), + lastPlayedArtists: _list(j, 'last_played_artists', ArtistRef.fromJson), + ); + + static List _list( + Map j, + String key, + T Function(Map) parse, + ) { + final raw = j[key] as List? ?? const []; + return raw + .map((e) => parse((e as Map).cast())) + .toList(growable: false); + } +} diff --git a/flutter_client/lib/models/track.dart b/flutter_client/lib/models/track.dart new file mode 100644 index 00000000..8c270776 --- /dev/null +++ b/flutter_client/lib/models/track.dart @@ -0,0 +1,43 @@ +// Mirrors internal/api/types.go TrackRef and web/src/lib/api/types.ts. +// +// Server uses `duration_sec` (NOT duration_ms). `track_number` and +// `disc_number` are omitempty server-side so they're nullable here. +// `stream_url` points at /api/tracks/{id}/stream. +class TrackRef { + const TrackRef({ + required this.id, + required this.title, + required this.albumId, + required this.artistId, + this.albumTitle = '', + this.artistName = '', + this.trackNumber, + this.discNumber, + this.durationSec = 0, + this.streamUrl = '', + }); + + final String id; + final String title; + final String albumId; + final String albumTitle; + final String artistId; + final String artistName; + final int? trackNumber; + final int? discNumber; + final int durationSec; + final String streamUrl; + + factory TrackRef.fromJson(Map j) => TrackRef( + id: j['id'] as String, + title: j['title'] as String, + albumId: j['album_id'] as String, + albumTitle: j['album_title'] as String? ?? '', + artistId: j['artist_id'] as String, + artistName: j['artist_name'] as String? ?? '', + trackNumber: (j['track_number'] as num?)?.toInt(), + discNumber: (j['disc_number'] as num?)?.toInt(), + durationSec: (j['duration_sec'] as num?)?.toInt() ?? 0, + streamUrl: j['stream_url'] as String? ?? '', + ); +} diff --git a/flutter_client/lib/models/user.dart b/flutter_client/lib/models/user.dart new file mode 100644 index 00000000..9c674f3b --- /dev/null +++ b/flutter_client/lib/models/user.dart @@ -0,0 +1,24 @@ +// Mirrors internal/api/types.go UserView. The /api/* user shape — narrower +// than dbq.User (no password hash, no api_token, no subsonic_password). +// +// `id` is a pgtype.UUID server-side which marshals to a plain string when +// Valid, so we accept it as String here. `is_admin` is always present in +// the server response but we tolerate its absence (default false) to keep +// older fixtures and partial mocks loadable. +class User { + const User({ + required this.id, + required this.username, + required this.isAdmin, + }); + + final String id; + final String username; + final bool isAdmin; + + factory User.fromJson(Map j) => User( + id: j['id'] as String, + username: j['username'] as String, + isAdmin: j['is_admin'] as bool? ?? false, + ); +} diff --git a/flutter_client/test/models/models_test.dart b/flutter_client/test/models/models_test.dart new file mode 100644 index 00000000..d1082091 --- /dev/null +++ b/flutter_client/test/models/models_test.dart @@ -0,0 +1,168 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:minstrel/models/album.dart'; +import 'package:minstrel/models/artist.dart'; +import 'package:minstrel/models/home_data.dart'; +import 'package:minstrel/models/track.dart'; +import 'package:minstrel/models/user.dart'; + +void main() { + group('User.fromJson', () { + test('parses canonical UserView shape', () { + final u = User.fromJson({ + 'id': 'usr-1', + 'username': 'alice', + 'is_admin': true, + }); + expect(u.id, 'usr-1'); + expect(u.username, 'alice'); + expect(u.isAdmin, isTrue); + }); + + test('defaults is_admin to false when missing', () { + final u = User.fromJson({'id': 'usr-2', 'username': 'bob'}); + expect(u.isAdmin, isFalse); + }); + }); + + group('ArtistRef.fromJson', () { + test('parses full payload using server cover_url field', () { + final a = ArtistRef.fromJson({ + 'id': 'art-1', + 'name': 'Boards of Canada', + 'sort_name': 'Boards of Canada', + 'album_count': 7, + 'cover_url': '/api/albums/al-1/cover', + }); + expect(a.name, 'Boards of Canada'); + expect(a.sortName, 'Boards of Canada'); + expect(a.albumCount, 7); + expect(a.coverUrl, '/api/albums/al-1/cover'); + }); + + test('tolerates missing optional fields', () { + final a = ArtistRef.fromJson({'id': 'art-2', 'name': 'Aphex Twin'}); + expect(a.sortName, ''); + expect(a.albumCount, 0); + expect(a.coverUrl, ''); + }); + }); + + group('AlbumRef.fromJson', () { + test('parses full payload with year and duration_sec', () { + final a = AlbumRef.fromJson({ + 'id': 'al-1', + 'title': 'Geogaddi', + 'sort_title': 'Geogaddi', + 'artist_id': 'art-1', + 'artist_name': 'Boards of Canada', + 'year': 2002, + 'track_count': 23, + 'duration_sec': 4020, + 'cover_url': '/api/albums/al-1/cover', + }); + expect(a.title, 'Geogaddi'); + expect(a.year, 2002); + expect(a.trackCount, 23); + expect(a.durationSec, 4020); + expect(a.coverUrl, '/api/albums/al-1/cover'); + }); + + test('leaves year null when absent', () { + final a = AlbumRef.fromJson({ + 'id': 'al-2', + 'title': 'Untitled', + 'artist_id': 'art-1', + }); + expect(a.year, isNull); + expect(a.trackCount, 0); + expect(a.durationSec, 0); + }); + }); + + group('TrackRef.fromJson', () { + test('parses full payload including stream_url', () { + final t = TrackRef.fromJson({ + 'id': 'tr-1', + 'title': 'Music Is Math', + 'album_id': 'al-1', + 'album_title': 'Geogaddi', + 'artist_id': 'art-1', + 'artist_name': 'Boards of Canada', + 'track_number': 5, + 'disc_number': 1, + 'duration_sec': 379, + 'stream_url': '/api/tracks/tr-1/stream', + }); + expect(t.title, 'Music Is Math'); + expect(t.trackNumber, 5); + expect(t.discNumber, 1); + expect(t.durationSec, 379); + expect(t.streamUrl, '/api/tracks/tr-1/stream'); + }); + + test('leaves track_number and disc_number null when omitted', () { + final t = TrackRef.fromJson({ + 'id': 'tr-2', + 'title': 'Hidden', + 'album_id': 'al-1', + 'artist_id': 'art-1', + }); + expect(t.trackNumber, isNull); + expect(t.discNumber, isNull); + }); + }); + + group('HomeData.fromJson', () { + test('handles missing sections as empty lists', () { + final h = HomeData.fromJson({}); + expect(h.recentlyAddedAlbums, isEmpty); + expect(h.rediscoverAlbums, isEmpty); + expect(h.rediscoverArtists, isEmpty); + expect(h.mostPlayedTracks, isEmpty); + expect(h.lastPlayedArtists, isEmpty); + }); + + test('parses populated payload across all five sections', () { + final h = HomeData.fromJson({ + 'recently_added_albums': [ + { + 'id': 'al-1', + 'title': 'Geogaddi', + 'artist_id': 'art-1', + 'artist_name': 'Boards of Canada', + 'cover_url': '/api/albums/al-1/cover', + } + ], + 'rediscover_albums': [ + { + 'id': 'al-2', + 'title': 'Music Has the Right to Children', + 'artist_id': 'art-1', + 'artist_name': 'Boards of Canada', + } + ], + 'rediscover_artists': [ + {'id': 'art-2', 'name': 'Aphex Twin'} + ], + 'most_played_tracks': [ + { + 'id': 'tr-1', + 'title': 'Roygbiv', + 'album_id': 'al-2', + 'artist_id': 'art-1', + 'duration_sec': 150, + } + ], + 'last_played_artists': [ + {'id': 'art-3', 'name': 'Tycho'} + ], + }); + expect(h.recentlyAddedAlbums.single.title, 'Geogaddi'); + expect(h.recentlyAddedAlbums.single.coverUrl, '/api/albums/al-1/cover'); + expect(h.rediscoverAlbums.single.id, 'al-2'); + expect(h.rediscoverArtists.single.name, 'Aphex Twin'); + expect(h.mostPlayedTracks.single.durationSec, 150); + expect(h.lastPlayedArtists.single.name, 'Tycho'); + }); + }); +}