yaml
This commit is contained in:
parent
d205277cdd
commit
d1cab09a4f
9 changed files with 850 additions and 140 deletions
113
codemagic.yaml
Normal file
113
codemagic.yaml
Normal 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
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,110 +35,280 @@ class HomeScreen extends ConsumerWidget {
|
|||
RatuneSpacing.lg,
|
||||
),
|
||||
children: [
|
||||
HairlinePanel(
|
||||
const _HeroCard(),
|
||||
const SizedBox(height: RatuneSpacing.xl),
|
||||
|
||||
// Recently Played — from local history.
|
||||
if (recentAlbums.isNotEmpty)
|
||||
_Shelf(
|
||||
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(
|
||||
cards: [
|
||||
for (final rec in recentAlbums)
|
||||
_ArtCard(
|
||||
artUri: artFor(rec.coverArt),
|
||||
title: rec.album ?? 'Unknown album',
|
||||
subtitle: rec.artist,
|
||||
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 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()),
|
||||
: () => _pushAlbum(context, rec.albumId!),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Recently Added — server discovery shelf.
|
||||
_AlbumShelf(
|
||||
title: 'Recently Added',
|
||||
albums: newest,
|
||||
artFor: artFor,
|
||||
),
|
||||
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
|
||||
|
||||
// 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)
|
||||
BrowseRow(
|
||||
_ArtCard(
|
||||
artUri: null,
|
||||
title: a.name,
|
||||
subtitle: 'Artist',
|
||||
fallbackIcon: Icons.person_outline,
|
||||
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)),
|
||||
|
||||
// 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -137,12 +316,127 @@ class HomeScreen extends ConsumerWidget {
|
|||
}
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,12 +196,18 @@ class _StatusBar extends ConsumerWidget {
|
|||
ConnStatus.disconnected => ('○', 'tap to connect', RatuneColors.dimmed),
|
||||
};
|
||||
|
||||
return InkWell(
|
||||
onTap: () => showConnectSheet(context),
|
||||
child: Container(
|
||||
return Container(
|
||||
height: 22,
|
||||
padding: const EdgeInsets.symmetric(horizontal: RatuneSpacing.lg),
|
||||
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)),
|
||||
|
|
@ -206,11 +219,31 @@ class _StatusBar extends ConsumerWidget {
|
|||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
169
test/features_test.dart
Normal 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue