This commit is contained in:
Forrest 2026-07-29 15:28:11 -04:00
parent d205277cdd
commit d1cab09a4f
9 changed files with 850 additions and 140 deletions

113
codemagic.yaml Normal file
View file

@ -0,0 +1,113 @@
# Codemagic CI/CD for ratune_mobile (Flutter).
# The repo root IS the Flutter project, so scripts run from the app directory
# with no `working_directory` override. Two workflows: Android (Linux) and
# iOS (mac). Docs: https://docs.codemagic.io/yaml/yaml-getting-started/
definitions:
# Shared quality gate — anchored per-step (YAML can't splice a list into a
# list, only reference individual map items). Toolchain is pinned inline in
# each workflow to Flutter 3.44.8 / Dart 3.12.2 (the local dev version);
# change to `stable` for the latest, or bump when you upgrade.
scripts:
- &flutter_version
name: Flutter version
script: flutter --version
- &get_deps
name: Get dependencies
script: flutter pub get
- &analyze
name: Analyze
script: flutter analyze
- &unit_tests
name: Unit tests
script: |
mkdir -p test-results
flutter test --machine --coverage > test-results/flutter.json
test_report: test-results/flutter.json
workflows:
# ---------------------------------------------------------------------------
android:
name: Android (APK + AAB)
instance_type: linux_x2
max_build_duration: 60
environment:
flutter: 3.44.8
java: 17
# Uncomment once you add a keystore to Codemagic (Teams → Code signing
# identities → Android) and reference its group here to sign releases.
# groups:
# - android_keystore # CM_KEYSTORE, CM_KEYSTORE_PASSWORD, CM_KEY_ALIAS, CM_KEY_PASSWORD
cache:
cache_paths:
- $HOME/.pub-cache
- $HOME/.gradle/caches
# triggering:
# events: [push, tag]
# branch_patterns:
# - pattern: main
# include: true
scripts:
- *flutter_version
- *get_deps
- *analyze
- *unit_tests
# NOTE: android/app/build.gradle.kts currently signs `release` with the
# DEBUG key (Flutter template default), so these build & install but are
# not upload-ready. To ship: add a real signingConfig backed by
# key.properties / the CI env vars above — this step then needs no change.
- name: Build APK (release)
script: flutter build apk --release
- name: Build App Bundle (release)
script: flutter build appbundle --release
artifacts:
- build/app/outputs/flutter-apk/*.apk
- build/app/outputs/bundle/**/*.aab
- build/app/outputs/**/mapping.txt
- flutter_drive.log
# publishing:
# email:
# recipients:
# - you@conversionpath.com
# notify:
# success: true
# failure: true
# # google_play:
# # credentials: $GCLOUD_SERVICE_ACCOUNT_CREDENTIALS
# # track: internal
# ---------------------------------------------------------------------------
ios:
name: iOS (unsigned build)
instance_type: mac_mini_m2
max_build_duration: 60
environment:
flutter: 3.44.8
java: 17
cache:
cache_paths:
- $HOME/.pub-cache
- $HOME/.gradle/caches
scripts:
- *flutter_version
- *get_deps
- *analyze
- *unit_tests
# Compiles the iOS app without code signing — verifies the build on CI.
# Produces a .app (not an installable/uploadable .ipa).
- name: Build iOS (no codesign)
script: flutter build ios --release --no-codesign
artifacts:
- build/ios/iphoneos/*.app
- flutter_drive.log
# --- To produce a signed, uploadable IPA instead: configure App Store
# --- Connect + signing in Codemagic (Teams → Integrations → App Store
# --- Connect), then add `ios_signing` to `environment` and replace the
# --- build step with:
# - name: Set up provisioning profiles
# script: xcode-project use-profiles
# - name: Build IPA
# script: flutter build ipa --release --export-options-plist=/Users/builder/export_options.plist
# --- with artifacts: build/ios/ipa/*.ipa and
# --- publishing.app_store_connect.submit_to_testflight: true

View file

@ -279,6 +279,12 @@ class DownloadController extends StateNotifier<DownloadState> {
return;
}
// Honor a removal that happened mid-download: don't resurrect the entry.
if (!state.byId.containsKey(song.id)) {
await File(tmpPath).delete().catchError((_) => File(tmpPath));
return;
}
final tmp = File(tmpPath);
await tmp.rename(finalPath);
final size = await File(finalPath).length();

View file

@ -11,7 +11,6 @@ import 'downloads_screen.dart';
import 'favorites_screen.dart';
import 'playlists_screen.dart';
import 'search_screen.dart';
import 'settings_screen.dart';
/// Browser tab — Artists / Albums / Tracks browse modes over the live Subsonic
/// server. Artists and Albums drill down by pushing onto the (nested) navigator;
@ -66,13 +65,6 @@ class BrowserScreen extends ConsumerWidget {
MaterialPageRoute(builder: (_) => const DownloadsScreen()),
),
),
_Action(
icon: Icons.settings,
label: 'Settings',
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
),
),
],
),
const SizedBox(height: RatuneSpacing.md),
@ -337,6 +329,7 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
: 'No tracks indexed yet.'),
);
} else {
final downloads = ref.watch(downloadManagerProvider);
body = ListView.builder(
padding: EdgeInsets.zero,
itemCount: index.songs.length,
@ -345,9 +338,16 @@ class _TracksPanelState extends ConsumerState<_TracksPanel> {
return BrowseRow(
title: song.title ?? 'Untitled',
trailing: song.artist,
downloadStatus: downloads.byId[song.id]?.status,
onTap: () => playback.playSongs(index.songs, startIndex: i),
onPlayNext: () => playback.playNext(song),
onAddToQueue: () => playback.addToQueue(song),
onAddToPlaylist: () =>
showAddToPlaylistSheet(context, songs: [song]),
onDownload: () =>
ref.read(downloadManagerProvider.notifier).download(song),
onRemoveDownload: () =>
ref.read(downloadManagerProvider.notifier).remove(song.id),
);
},
);
@ -451,8 +451,36 @@ class AlbumScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(albumProvider(id));
final downloads = ref.watch(downloadManagerProvider);
final playback = ref.read(playbackProvider.notifier);
final songs = album.valueOrNull?.songs ?? const <Song>[];
return _DetailScaffold(
title: album.valueOrNull?.name ?? 'Album',
actions: songs.isEmpty
? null
: [
IconButton(
tooltip: 'Add to playlist',
onPressed: () =>
showAddToPlaylistSheet(context, songs: songs),
icon: const Icon(Icons.playlist_add),
),
IconButton(
tooltip: 'Download album',
onPressed: () {
ref
.read(downloadManagerProvider.notifier)
.downloadAll(songs);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Downloading album…'),
duration: Duration(seconds: 2)),
);
},
icon: const Icon(Icons.download),
),
],
child: album.when(
loading: () => const _Centered(child: _Loading()),
error: (e, _) => _Centered(child: _ErrorText('$e')),
@ -461,14 +489,20 @@ class AlbumScreen extends ConsumerWidget {
itemCount: a.songs.length,
itemBuilder: (context, i) {
final song = a.songs[i];
final playback = ref.read(playbackProvider.notifier);
return BrowseRow(
leading: song.track?.toString(),
title: song.title ?? 'Untitled',
trailing: _fmtDuration(song.duration),
downloadStatus: downloads.byId[song.id]?.status,
onTap: () => playback.playSongs(a.songs, startIndex: i),
onPlayNext: () => playback.playNext(song),
onAddToQueue: () => playback.addToQueue(song),
onAddToPlaylist: () =>
showAddToPlaylistSheet(context, songs: [song]),
onDownload: () =>
ref.read(downloadManagerProvider.notifier).download(song),
onRemoveDownload: () =>
ref.read(downloadManagerProvider.notifier).remove(song.id),
);
},
),

View file

@ -1,13 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../history/play_history.dart';
import '../subsonic/models.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import '../widgets/hairline_panel.dart';
import '../widgets/block_progress_bar.dart';
import 'browser_screen.dart';
/// Home tab — Recently Played (album-art strip), Recent Tracks, and Rediscover,
/// all derived from the local play history (mirrors Ratune's home tab).
/// Home tab — a "Resume" hero bound to live playback, then horizontal art
/// shelves. Reworked from the terminal-style dense list into a touch-first
/// mobile layout while keeping the app's angular, hairline aesthetic.
class HomeScreen extends ConsumerWidget {
const HomeScreen({super.key});
@ -15,8 +18,14 @@ class HomeScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final client = ref.watch(subsonicClientProvider);
final recentAlbums = ref.watch(recentAlbumsProvider);
final recentSongs = ref.watch(recentSongsProvider);
final rediscover = ref.watch(rediscoverProvider);
final newest = ref.watch(newestAlbumsProvider);
final random = ref.watch(randomAlbumsProvider);
String? artFor(String? coverArt, {int size = 300}) =>
(client != null && coverArt != null)
? client.coverArtUri(coverArt, size: size).toString()
: null;
return ListView(
padding: const EdgeInsets.fromLTRB(
@ -26,123 +35,408 @@ class HomeScreen extends ConsumerWidget {
RatuneSpacing.lg,
),
children: [
HairlinePanel(
title: 'Recently Played',
child: SizedBox(
height: 120,
child: recentAlbums.isEmpty
? const _Empty('No listening history yet.')
: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: recentAlbums.length,
separatorBuilder: (_, _) =>
const SizedBox(width: RatuneSpacing.md),
itemBuilder: (_, i) {
final rec = recentAlbums[i];
final art = (client != null && rec.coverArt != null)
? client.coverArtUri(rec.coverArt!, size: 240).toString()
: null;
return GestureDetector(
onTap: rec.albumId == null
? null
: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) =>
AlbumScreen(id: rec.albumId!),
),
),
child: SizedBox(
width: 120,
child: ColoredBox(
color: RatuneColors.surface,
child: art != null
? Image.network(art,
fit: BoxFit.cover, gaplessPlayback: true)
: const Icon(Icons.album_outlined,
color: RatuneColors.dimmed),
),
),
);
},
),
),
),
const _HeroCard(),
const SizedBox(height: RatuneSpacing.xl),
HairlinePanel(
title: 'Recent Tracks',
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: recentSongs.isEmpty
? const Padding(
padding: EdgeInsets.all(RatuneSpacing.lg),
child: _Empty('Nothing played recently.'),
)
: Column(
children: [
for (final rec in recentSongs.take(8))
BrowseRow(
title: rec.title,
trailing: rec.artist,
onTap: () => ref
.read(playbackProvider.notifier)
.playSongs([rec.toSong()]),
onPlayNext: () => ref
.read(playbackProvider.notifier)
.playNext(rec.toSong()),
onAddToQueue: () => ref
.read(playbackProvider.notifier)
.addToQueue(rec.toSong()),
),
],
// Recently Played — from local history.
if (recentAlbums.isNotEmpty)
_Shelf(
title: 'Recently Played',
cards: [
for (final rec in recentAlbums)
_ArtCard(
artUri: artFor(rec.coverArt),
title: rec.album ?? 'Unknown album',
subtitle: rec.artist,
onTap: rec.albumId == null
? null
: () => _pushAlbum(context, rec.albumId!),
),
),
const SizedBox(height: RatuneSpacing.xl),
HairlinePanel(
title: 'Rediscover',
padding: const EdgeInsets.symmetric(vertical: RatuneSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (rediscover.isEmpty)
const Padding(
padding: EdgeInsets.all(RatuneSpacing.lg),
child: _Empty('Listen to more music to unlock suggestions.'),
)
else
for (final a in rediscover)
BrowseRow(
title: a.name,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ArtistScreen(id: a.artistId),
),
),
),
InkWell(
onTap: () =>
ref.read(rediscoverSeedProvider.notifier).state++,
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: RatuneSpacing.lg,
vertical: RatuneSpacing.md,
),
child: Text('↻ re-roll',
style: TextStyle(color: RatuneColors.dimmed)),
),
),
],
),
// Recently Added — server discovery shelf.
_AlbumShelf(
title: 'Recently Added',
albums: newest,
artFor: artFor,
),
// Made For You — rediscover artists you're neglecting.
if (rediscover.isNotEmpty)
_Shelf(
title: 'Made For You',
onShuffle: () => ref.read(rediscoverSeedProvider.notifier).state++,
cards: [
for (final a in rediscover)
_ArtCard(
artUri: null,
title: a.name,
subtitle: 'Artist',
fallbackIcon: Icons.person_outline,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ArtistScreen(id: a.artistId),
),
),
),
],
),
// Random — server discovery shelf, shares the shuffle affordance.
_AlbumShelf(
title: 'Random',
albums: random,
artFor: artFor,
onShuffle: () => ref.read(rediscoverSeedProvider.notifier).state++,
),
if (recentAlbums.isEmpty && client == null)
const Padding(
padding: EdgeInsets.only(top: RatuneSpacing.xl),
child: Text(
'Connect to a server and start listening — your home fills in as you play.',
style: TextStyle(color: RatuneColors.dimmed),
),
),
],
);
}
static void _pushAlbum(BuildContext context, String albumId) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => AlbumScreen(id: albumId)),
);
}
}
/// Live "Resume" hero. Isolated so playback position ticks rebuild only this
/// card, not the whole Home list. Tapping jumps to Now Playing (or plays the
/// most recent track when nothing is loaded).
class _HeroCard extends ConsumerWidget {
const _HeroCard();
@override
Widget build(BuildContext context, WidgetRef ref) {
final accent = Theme.of(context).colorScheme.primary;
final client = ref.watch(subsonicClientProvider);
final current = ref.watch(playbackProvider.select((s) => s.current));
final progress = ref.watch(playbackProvider.select((s) => s.progress));
// Fall back to the most recent track so the hero is useful before playback.
final recent = ref.watch(recentSongsProvider);
final PlayRecord? fallback = recent.isEmpty ? null : recent.first;
final String? coverArt = current?.coverArt ?? fallback?.coverArt;
final artUri = (client != null && coverArt != null)
? client.coverArtUri(coverArt, size: 240).toString()
: null;
final title = current?.title ?? fallback?.title;
final subtitle = current?.artist ?? fallback?.artist;
final hasCurrent = current != null;
if (title == null) {
return _HeroShell(
accent: accent,
onTap: () => ref.read(selectedTabProvider.notifier).state = 1,
child: const Row(
children: [
Icon(Icons.library_music_outlined,
color: RatuneColors.dimmed, size: 40),
SizedBox(width: RatuneSpacing.lg),
Expanded(
child: Text('Browse your library to start listening',
style: TextStyle(color: RatuneColors.foreground)),
),
],
),
);
}
return _HeroShell(
accent: accent,
onTap: () {
if (hasCurrent) {
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex;
} else if (fallback != null) {
ref.read(playbackProvider.notifier).playSongs([fallback.toSong()]);
ref.read(selectedTabProvider.notifier).state = nowPlayingTabIndex;
}
},
child: Row(
children: [
SizedBox(
width: 64,
height: 64,
child: ColoredBox(
color: RatuneColors.surface,
child: artUri != null
? Image.network(artUri,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) => const Icon(
Icons.album_outlined, color: RatuneColors.dimmed))
: const Icon(Icons.album_outlined,
color: RatuneColors.dimmed),
),
),
const SizedBox(width: RatuneSpacing.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(hasCurrent ? Icons.play_arrow : Icons.history,
size: 14, color: accent),
const SizedBox(width: RatuneSpacing.xs),
Text(hasCurrent ? 'NOW PLAYING' : 'RESUME',
style: TextStyle(
color: accent,
fontSize: 11,
letterSpacing: 1,
fontWeight: FontWeight.w700)),
],
),
const SizedBox(height: RatuneSpacing.xs),
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: RatuneColors.foreground,
fontWeight: FontWeight.w700)),
if (subtitle != null)
Text(subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.dimmed)),
if (hasCurrent) ...[
const SizedBox(height: RatuneSpacing.md),
BlockProgressBar(progress: progress, cells: 32, height: 6),
],
],
),
),
],
),
);
}
}
class _HeroShell extends StatelessWidget {
const _HeroShell(
{required this.child, required this.accent, required this.onTap});
final Widget child;
final Color accent;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(RatuneSpacing.lg),
decoration: BoxDecoration(
color: RatuneColors.surface,
border: Border.all(color: RatuneColors.borderActive),
),
child: child,
),
);
}
}
/// A horizontal shelf: a header (with an optional shuffle action) over a
/// scrolling row of art cards.
class _Shelf extends StatelessWidget {
const _Shelf({required this.title, required this.cards, this.onShuffle});
final String title;
final List<Widget> cards;
final VoidCallback? onShuffle;
@override
Widget build(BuildContext context) {
if (cards.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: RatuneSpacing.xl),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ShelfHeader(title: title, onShuffle: onShuffle),
const SizedBox(height: RatuneSpacing.md),
SizedBox(
height: 182,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: cards.length,
separatorBuilder: (_, _) =>
const SizedBox(width: RatuneSpacing.md),
itemBuilder: (_, i) => cards[i],
),
),
],
),
);
}
}
class _ShelfHeader extends StatelessWidget {
const _ShelfHeader({required this.title, this.onShuffle});
final String title;
final VoidCallback? onShuffle;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(title,
style: const TextStyle(
color: RatuneColors.foreground,
fontWeight: FontWeight.w700,
letterSpacing: 0.5)),
const Spacer(),
if (onShuffle != null)
InkWell(
onTap: onShuffle,
customBorder: const CircleBorder(),
child: const SizedBox(
width: RatuneSpacing.minTouchTarget,
height: 28,
child: Icon(Icons.shuffle, size: 18, color: RatuneColors.dimmed),
),
),
],
);
}
}
class _Empty extends StatelessWidget {
const _Empty(this.message);
final String message;
/// An async album shelf backed by a FutureProvider. Renders a placeholder row
/// while loading and disappears when empty / errored / offline.
class _AlbumShelf extends StatelessWidget {
const _AlbumShelf({
required this.title,
required this.albums,
required this.artFor,
this.onShuffle,
});
final String title;
final AsyncValue<List<Album>> albums;
final String? Function(String? coverArt, {int size}) artFor;
final VoidCallback? onShuffle;
@override
Widget build(BuildContext context) => Align(
alignment: Alignment.centerLeft,
child: Text(message, style: const TextStyle(color: RatuneColors.dimmed)),
);
Widget build(BuildContext context) {
return albums.when(
loading: () => _Shelf(
title: title,
onShuffle: onShuffle,
cards: const [_ArtCardSkeleton(), _ArtCardSkeleton(), _ArtCardSkeleton()],
),
error: (_, _) => const SizedBox.shrink(),
data: (list) => _Shelf(
title: title,
onShuffle: onShuffle,
cards: [
for (final a in list)
_ArtCard(
artUri: artFor(a.coverArt),
title: a.name ?? 'Unknown album',
subtitle: a.artist,
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => AlbumScreen(id: a.id)),
),
),
],
),
);
}
}
/// A fixed-width art tile with a caption — the shelf's building block.
class _ArtCard extends StatelessWidget {
const _ArtCard({
required this.title,
required this.artUri,
this.subtitle,
this.onTap,
this.fallbackIcon = Icons.album_outlined,
});
final String title;
final String? artUri;
final String? subtitle;
final VoidCallback? onTap;
final IconData fallbackIcon;
static const double _size = 132;
@override
Widget build(BuildContext context) {
return SizedBox(
width: _size,
child: InkWell(
onTap: onTap,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: SizedBox(
width: _size,
height: _size,
child: ColoredBox(
color: RatuneColors.surface,
child: artUri != null
? Image.network(artUri!,
key: ValueKey(artUri),
fit: BoxFit.cover,
gaplessPlayback: true,
errorBuilder: (_, _, _) =>
Icon(fallbackIcon, color: RatuneColors.dimmed))
: Icon(fallbackIcon, color: RatuneColors.dimmed),
),
),
),
const SizedBox(height: RatuneSpacing.sm),
Text(title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: RatuneColors.foreground)),
if (subtitle != null)
Text(subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style:
const TextStyle(color: RatuneColors.dimmed, fontSize: 12)),
],
),
),
);
}
}
class _ArtCardSkeleton extends StatelessWidget {
const _ArtCardSkeleton();
@override
Widget build(BuildContext context) {
return SizedBox(
width: _ArtCard._size,
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: const SizedBox(
width: _ArtCard._size,
height: _ArtCard._size,
child: ColoredBox(color: RatuneColors.surface),
),
),
);
}
}

View file

@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:just_audio/just_audio.dart' show LoopMode;
import '../downloads/download_manager.dart';
import '../playback/playback_engine.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';
import '../widgets/block_progress_bar.dart';
import '../widgets/hairline_panel.dart';
import 'add_to_playlist_sheet.dart';
/// Now Playing tab — album art + info strip + transport, bound to the live
/// playback engine. The top region shows the full-size album art by default and
@ -249,6 +251,12 @@ class _FavRating extends ConsumerWidget {
final rating =
fav.ratingFor(song.id) != 0 ? fav.ratingFor(song.id) : (song.userRating ?? 0);
final downloadStatus =
ref.watch(downloadManagerProvider.select((s) => s.byId[song.id]?.status));
final isDownloaded = downloadStatus == DownloadStatus.done;
final isDownloading = downloadStatus == DownloadStatus.queued ||
downloadStatus == DownloadStatus.downloading;
return Row(
children: [
InkWell(
@ -278,6 +286,41 @@ class _FavRating extends ConsumerWidget {
),
),
),
const Spacer(),
InkWell(
onTap: () => showAddToPlaylistSheet(context, songs: [song]),
customBorder: const CircleBorder(),
child: const Padding(
padding: EdgeInsets.all(RatuneSpacing.sm),
child: Icon(Icons.playlist_add,
size: 22, color: RatuneColors.dimmed),
),
),
InkWell(
onTap: isDownloading
? null
: () {
final dm = ref.read(downloadManagerProvider.notifier);
isDownloaded ? dm.remove(song.id) : dm.download(song);
},
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(RatuneSpacing.sm),
child: isDownloading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Icon(
isDownloaded
? Icons.download_done
: Icons.download_outlined,
size: 22,
color: isDownloaded ? accent : RatuneColors.dimmed,
),
),
),
],
);
}

View file

@ -2,6 +2,7 @@ import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../playlists/playlists.dart';
import '../state/providers.dart';
import '../subsonic/models.dart';
import '../theme/tokens.dart';

View file

@ -6,6 +6,7 @@ import '../screens/browser_screen.dart';
import '../screens/connect_sheet.dart';
import '../screens/home_screen.dart';
import '../screens/now_playing_screen.dart';
import '../screens/settings_screen.dart';
import '../state/providers.dart';
import '../theme/tokens.dart';
import '../widgets/mini_player.dart';
@ -52,6 +53,12 @@ class _AppShellState extends ConsumerState<AppShell> {
// Keep favorites alive from launch so its connect/disconnect listener runs
// and hydrates stars/ratings as soon as a server connects.
ref.watch(favoritesProvider);
// Instantiate the download + playlist stores at launch too, so their
// per-server manifests load from disk before the user can trigger
// playback — otherwise the first play right after a cold (offline) boot
// races the async manifest load and misses a downloaded file.
ref.watch(downloadManagerProvider);
ref.watch(playlistsProvider);
final index = ref.watch(selectedTabProvider);
return PopScope(
@ -189,27 +196,53 @@ class _StatusBar extends ConsumerWidget {
ConnStatus.disconnected => ('○', 'tap to connect', RatuneColors.dimmed),
};
return InkWell(
onTap: () => showConnectSheet(context),
child: Container(
height: 22,
padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
color: RatuneColors.surface,
child: Row(
children: [
Text('$glyph ', style: TextStyle(color: color)),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: color),
return Container(
height: 22,
color: RatuneColors.surface,
child: Row(
children: [
// Left: connection status — tap to open the connect sheet.
Expanded(
child: InkWell(
onTap: () => showConnectSheet(context),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
child: Row(
children: [
Text('$glyph ', style: TextStyle(color: color)),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: color),
),
),
],
),
),
),
const SizedBox(width: RatuneSpacing.md),
const Text('i — help', style: TextStyle(color: RatuneColors.dimmed)),
],
),
),
// Right: settings — pushes over the whole shell (root navigator).
InkWell(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsScreen()),
),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.settings, size: 12, color: RatuneColors.dimmed),
SizedBox(width: RatuneSpacing.xs),
Text('settings',
style: TextStyle(color: RatuneColors.dimmed)),
],
),
),
),
],
),
);
}

View file

@ -195,6 +195,23 @@ final libraryIndexProvider =
return controller;
});
/// Recently-added albums (`getAlbumList2` type `newest`) — a discovery shelf on
/// the Home tab. Small page; the Home shelf shows the first handful.
final newestAlbumsProvider = FutureProvider<List<Album>>((ref) async {
final client = ref.watch(subsonicClientProvider);
if (client == null) return const [];
return client.getAlbumList2(type: 'newest', size: 20);
});
/// A random album shelf (`getAlbumList2` type `random`). Re-rolls when
/// [rediscoverSeedProvider] bumps so it shares the Home "shuffle" affordance.
final randomAlbumsProvider = FutureProvider<List<Album>>((ref) async {
ref.watch(rediscoverSeedProvider);
final client = ref.watch(subsonicClientProvider);
if (client == null) return const [];
return client.getAlbumList2(type: 'random', size: 20);
});
final artistProvider = FutureProvider.family<Artist, String>((ref, id) async {
final client = ref.watch(subsonicClientProvider);
if (client == null) throw StateError('Not connected');

169
test/features_test.dart Normal file
View file

@ -0,0 +1,169 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:ratune_mobile/playlists/playlists.dart';
import 'package:ratune_mobile/settings/settings_store.dart';
import 'package:ratune_mobile/subsonic/models.dart';
import 'package:ratune_mobile/subsonic/subsonic_client.dart';
/// Fake client so playlist mutations can be tested without a server. Methods
/// are overridden; the (unused) real Dio is created but never hit.
class _FakeClient extends SubsonicClient {
_FakeClient() : super(baseUrl: 'http://x', username: 'u', password: 'p');
final List<Playlist> playlists = [];
final Map<String, PlaylistDetail> details = {};
bool failNext = false;
int _seq = 0;
void _maybeThrow() {
if (failNext) {
failNext = false;
throw Exception('boom');
}
}
@override
Future<List<Playlist>> getPlaylists() async => List.of(playlists);
@override
Future<PlaylistDetail> getPlaylist(String id) async =>
details[id] ?? PlaylistDetail(id: id, name: 'x');
@override
Future<PlaylistDetail?> createPlaylist(String name) async {
_maybeThrow();
final d = PlaylistDetail(id: 'pl-${_seq++}', name: name);
playlists.add(d.toSummary());
details[d.id] = d;
return d;
}
@override
Future<void> addTracksToPlaylist(String playlistId, List<String> songIds) async {
_maybeThrow();
}
@override
Future<void> removeTrackFromPlaylist(String playlistId, int index) async {
_maybeThrow();
}
@override
Future<void> renamePlaylist(String playlistId, String name) async {
_maybeThrow();
}
@override
Future<void> deletePlaylist(String id) async {
_maybeThrow();
}
}
Song _song(String id) => Song(id: id, title: 'Song $id');
void main() {
group('model round-trips', () {
test('Playlist toJson/fromJson', () {
final p = Playlist(id: 'p1', name: 'Mix', songCount: 3, duration: 600);
final back = Playlist.fromJson(p.toJson());
expect(back.id, 'p1');
expect(back.name, 'Mix');
expect(back.songCount, 3);
expect(back.duration, 600);
});
test('PlaylistDetail parses tracks from the `entry` key', () {
final detail = PlaylistDetail.fromJson({
'id': 'p1',
'name': 'Mix',
'entry': [
{'id': 's1', 'title': 'One'},
{'id': 's2', 'title': 'Two'},
],
});
expect(detail.songs.length, 2);
expect(detail.songs.first.id, 's1');
// toJson emits back under `entry` so the offline mirror round-trips.
expect(PlaylistDetail.fromJson(detail.toJson()).songs.length, 2);
});
});
group('AppSettings', () {
test('copyWith preserves format when the arg is omitted', () {
const s = AppSettings(downloadFormat: 'mp3');
expect(s.copyWith(streamMaxBitRate: 320).downloadFormat, 'mp3');
});
test('copyWith(downloadFormat: null) clears to Original', () {
const s = AppSettings(downloadFormat: 'mp3');
expect(s.copyWith(downloadFormat: null).downloadFormat, isNull);
});
test('toJson/fromJson round-trip', () {
const s = AppSettings(
streamMaxBitRate: 192, downloadMaxBitRate: 0, downloadFormat: 'opus');
final back = AppSettings.fromJson(s.toJson());
expect(back.streamMaxBitRate, 192);
expect(back.downloadMaxBitRate, 0);
expect(back.downloadFormat, 'opus');
});
});
group('PlaylistsController optimistic mutations', () {
late _FakeClient client;
late PlaylistsController controller;
setUp(() {
client = _FakeClient();
// Null server key => no filesystem access (persist/reload are no-ops).
controller = PlaylistsController(
clientGetter: () => client,
serverKeyGetter: () => null,
);
});
test('create adds a playlist and returns its id', () async {
final id = await controller.create('Roadtrip');
expect(id, isNotNull);
expect(controller.state.playlists.map((p) => p.name), contains('Roadtrip'));
expect(controller.state.details[id!]!.name, 'Roadtrip');
});
test('addTracks appends optimistically and bumps the count', () async {
final id = (await controller.create('Mix'))!;
await controller.addTracks(id, [_song('a'), _song('b')]);
expect(controller.state.details[id]!.songs.length, 2);
final summary = controller.state.playlists.firstWhere((p) => p.id == id);
expect(summary.songCount, 2);
});
test('removeAt drops the track optimistically', () async {
final id = (await controller.create('Mix'))!;
await controller.addTracks(id, [_song('a'), _song('b')]);
await controller.removeAt(id, 0);
final songs = controller.state.details[id]!.songs;
expect(songs.length, 1);
expect(songs.single.id, 'b');
});
test('rename updates the summary and cached detail', () async {
final id = (await controller.create('Old'))!;
await controller.rename(id, 'New');
expect(controller.state.playlists.single.name, 'New');
expect(controller.state.details[id]!.name, 'New');
});
test('delete removes the playlist', () async {
final id = (await controller.create('Temp'))!;
await controller.delete(id);
expect(controller.state.playlists, isEmpty);
expect(controller.state.details.containsKey(id), isFalse);
});
test('rename reverts when the server call fails', () async {
final id = (await controller.create('Keep'))!;
client.failNext = true;
await controller.rename(id, 'Nope');
expect(controller.state.playlists.single.name, 'Keep');
});
});
}