b466b6494a
Three small parity gaps in _HiddenTab vs the web /library/hidden page: - No unhide affordance — once a track was flagged via the kebab, the only way to reverse it was to find the track in another surface and toggle via the kebab again. Added an Icons.restore IconButton on each tile that calls myQuarantineProvider.notifier.unflag(trackId) (the optimistic remove-with-rollback already lived on the notifier). - No album cover art — added a 56px ServerImage thumb matching the web page's 14×14 thumb, with the same fs.slate fallback compact_track_card uses for missing covers. - No relative timestamp — appended _relativeTime(row.createdAt) next to the reason pill so the user can tell "I hid this 3d ago" at a glance. Also collapsed the duplicate provider: _HiddenTab was watching a local FutureProvider that didn't see flag/unflag mutations, while the kebab's HideTrackSheet flow goes through the canonical myQuarantineProvider (AsyncNotifier). Switched _HiddenTab to watch myQuarantineProvider so flag-from-anywhere and unhide-from-the-tab stay in sync. The local _quarantineProvider was deleted; one source of truth now. Caught during the #375 DRY audit cross-check against the #356 inventory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
623 lines
22 KiB
Dart
623 lines
22 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../api/endpoints/library_lists.dart';
|
|
import '../api/endpoints/likes.dart';
|
|
import '../api/endpoints/me.dart';
|
|
import '../cache/metadata_prefetcher.dart';
|
|
import '../library/library_providers.dart' show dioProvider;
|
|
import '../models/album.dart';
|
|
import '../models/artist.dart';
|
|
import '../models/history_event.dart';
|
|
// Aliased: Flutter's Material exports a `Page` class (Navigator 2.0
|
|
// route descriptor) that conflicts with our wire-format Page<T>.
|
|
import '../models/page.dart' as wire;
|
|
import '../models/quarantine_mine.dart';
|
|
import '../models/track.dart';
|
|
import '../player/player_provider.dart';
|
|
import '../quarantine/quarantine_provider.dart';
|
|
import '../shared/widgets/main_app_bar_actions.dart';
|
|
import '../shared/widgets/server_image.dart';
|
|
import '../theme/theme_extension.dart';
|
|
import 'widgets/album_card.dart';
|
|
import 'widgets/artist_card.dart';
|
|
import 'widgets/track_row.dart';
|
|
|
|
// Providers scoped to this screen. Each tab gets its own first page.
|
|
// Infinite scroll is a future enhancement; for v1 phone testing the
|
|
// first 50 rows per tab is enough.
|
|
|
|
final _libraryListsApiProvider = FutureProvider<LibraryListsApi>((ref) async {
|
|
return LibraryListsApi(await ref.watch(dioProvider.future));
|
|
});
|
|
|
|
final _likesApiProvider = FutureProvider<LikesApi>((ref) async {
|
|
return LikesApi(await ref.watch(dioProvider.future));
|
|
});
|
|
|
|
final _meApiProvider = FutureProvider<MeApi>((ref) async {
|
|
return MeApi(await ref.watch(dioProvider.future));
|
|
});
|
|
|
|
/// Paginated artist list. AsyncNotifier so the screen can call
|
|
/// `loadMore()` when the scroll approaches the bottom; subsequent
|
|
/// pages append to the existing items rather than replacing them.
|
|
class _LibraryArtistsNotifier extends AsyncNotifier<wire.Paged<ArtistRef>> {
|
|
static const _pageSize = 50;
|
|
bool _loadingMore = false;
|
|
|
|
@override
|
|
Future<wire.Paged<ArtistRef>> build() async {
|
|
final api = await ref.watch(_libraryListsApiProvider.future);
|
|
return api.listArtists(limit: _pageSize, offset: 0);
|
|
}
|
|
|
|
Future<void> loadMore() async {
|
|
if (_loadingMore) return;
|
|
final cur = state.value;
|
|
if (cur == null) return;
|
|
if (cur.items.length >= cur.total) return;
|
|
_loadingMore = true;
|
|
try {
|
|
final api = await ref.read(_libraryListsApiProvider.future);
|
|
final next = await api.listArtists(
|
|
limit: _pageSize,
|
|
offset: cur.items.length,
|
|
);
|
|
state = AsyncData(wire.Paged(
|
|
items: [...cur.items, ...next.items],
|
|
total: next.total,
|
|
limit: next.limit,
|
|
offset: 0,
|
|
));
|
|
} catch (_) {
|
|
// Swallow — the next near-bottom event will retry. The current
|
|
// partial list stays visible.
|
|
} finally {
|
|
_loadingMore = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
final _libraryArtistsProvider = AsyncNotifierProvider<
|
|
_LibraryArtistsNotifier,
|
|
wire.Paged<ArtistRef>>(_LibraryArtistsNotifier.new);
|
|
|
|
class _LibraryAlbumsNotifier extends AsyncNotifier<wire.Paged<AlbumRef>> {
|
|
static const _pageSize = 50;
|
|
bool _loadingMore = false;
|
|
|
|
@override
|
|
Future<wire.Paged<AlbumRef>> build() async {
|
|
final api = await ref.watch(_libraryListsApiProvider.future);
|
|
return api.listAlbums(limit: _pageSize, offset: 0);
|
|
}
|
|
|
|
Future<void> loadMore() async {
|
|
if (_loadingMore) return;
|
|
final cur = state.value;
|
|
if (cur == null) return;
|
|
if (cur.items.length >= cur.total) return;
|
|
_loadingMore = true;
|
|
try {
|
|
final api = await ref.read(_libraryListsApiProvider.future);
|
|
final next = await api.listAlbums(
|
|
limit: _pageSize,
|
|
offset: cur.items.length,
|
|
);
|
|
state = AsyncData(wire.Paged(
|
|
items: [...cur.items, ...next.items],
|
|
total: next.total,
|
|
limit: next.limit,
|
|
offset: 0,
|
|
));
|
|
} catch (_) {
|
|
// Swallow — next scroll event will retry.
|
|
} finally {
|
|
_loadingMore = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
final _libraryAlbumsProvider = AsyncNotifierProvider<
|
|
_LibraryAlbumsNotifier,
|
|
wire.Paged<AlbumRef>>(_LibraryAlbumsNotifier.new);
|
|
|
|
final _historyProvider = FutureProvider<HistoryPage>((ref) async {
|
|
return (await ref.watch(_meApiProvider.future)).history();
|
|
});
|
|
|
|
final _likedTracksProvider = FutureProvider<wire.Paged<TrackRef>>((ref) async {
|
|
return (await ref.watch(_likesApiProvider.future)).listTracks();
|
|
});
|
|
|
|
final _likedAlbumsProvider = FutureProvider<wire.Paged<AlbumRef>>((ref) async {
|
|
return (await ref.watch(_likesApiProvider.future)).listAlbums();
|
|
});
|
|
|
|
final _likedArtistsProvider = FutureProvider<wire.Paged<ArtistRef>>((ref) async {
|
|
return (await ref.watch(_likesApiProvider.future)).listArtists();
|
|
});
|
|
|
|
// Hidden tab uses the canonical `myQuarantineProvider` (AsyncNotifier with
|
|
// optimistic flag/unflag) so flagging from any kebab and unflagging from
|
|
// the Hidden tab keep one source of truth.
|
|
|
|
class LibraryScreen extends ConsumerStatefulWidget {
|
|
const LibraryScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<LibraryScreen> createState() => _LibraryScreenState();
|
|
}
|
|
|
|
class _LibraryScreenState extends ConsumerState<LibraryScreen>
|
|
with TickerProviderStateMixin {
|
|
late final TabController _ctrl = TabController(length: 5, vsync: this);
|
|
|
|
@override
|
|
void dispose() {
|
|
_ctrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
return Scaffold(
|
|
backgroundColor: fs.obsidian,
|
|
appBar: AppBar(
|
|
backgroundColor: fs.obsidian,
|
|
elevation: 0,
|
|
title: Text('Library', style: TextStyle(color: fs.parchment)),
|
|
actions: const [MainAppBarActions(currentRoute: '/library')],
|
|
bottom: TabBar(
|
|
controller: _ctrl,
|
|
isScrollable: true,
|
|
tabAlignment: TabAlignment.start,
|
|
labelColor: fs.parchment,
|
|
unselectedLabelColor: fs.ash,
|
|
indicatorColor: fs.accent,
|
|
tabs: const [
|
|
Tab(text: 'Artists'),
|
|
Tab(text: 'Albums'),
|
|
Tab(text: 'History'),
|
|
Tab(text: 'Liked'),
|
|
Tab(text: 'Hidden'),
|
|
],
|
|
),
|
|
),
|
|
body: TabBarView(
|
|
controller: _ctrl,
|
|
children: const [
|
|
_ArtistsTab(),
|
|
_AlbumsTab(),
|
|
_HistoryTab(),
|
|
_LikedTab(),
|
|
_HiddenTab(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ArtistsTab extends ConsumerWidget {
|
|
const _ArtistsTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
return ref.watch(_libraryArtistsProvider).when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
|
data: (page) {
|
|
// Warm details for the first screenful so taps are instant.
|
|
ref
|
|
.read(metadataPrefetcherProvider)
|
|
.warmArtists(page.items.map((a) => a.id));
|
|
return page.items.isEmpty
|
|
? Center(child: Text("No artists yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
|
: RefreshIndicator(
|
|
onRefresh: () async {
|
|
ref.invalidate(_libraryArtistsProvider);
|
|
await ref.read(_libraryArtistsProvider.future);
|
|
},
|
|
// Listen for scroll-near-bottom and ask the notifier
|
|
// to fetch the next page. 800px lookahead so the next
|
|
// page lands before the user reaches the visible end.
|
|
child: NotificationListener<ScrollNotification>(
|
|
onNotification: (n) {
|
|
if (n.metrics.pixels >=
|
|
n.metrics.maxScrollExtent - 800) {
|
|
ref
|
|
.read(_libraryArtistsProvider.notifier)
|
|
.loadMore();
|
|
}
|
|
return false;
|
|
},
|
|
child: GridView.builder(
|
|
padding: const EdgeInsets.all(8),
|
|
gridDelegate:
|
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: 3,
|
|
mainAxisSpacing: 8,
|
|
crossAxisSpacing: 8,
|
|
childAspectRatio: 0.78,
|
|
),
|
|
itemCount: page.items.length,
|
|
itemBuilder: (ctx, i) {
|
|
final artist = page.items[i];
|
|
return ArtistCard(
|
|
artist: artist,
|
|
onTap: () => ctx.push('/artists/${artist.id}',
|
|
extra: artist),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AlbumsTab extends ConsumerWidget {
|
|
const _AlbumsTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
return ref.watch(_libraryAlbumsProvider).when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
|
data: (page) {
|
|
return page.items.isEmpty
|
|
? Center(child: Text("No albums yet — scan a library folder via the server's config.", style: TextStyle(color: fs.ash), textAlign: TextAlign.center))
|
|
: RefreshIndicator(
|
|
onRefresh: () async {
|
|
ref.invalidate(_libraryAlbumsProvider);
|
|
await ref.read(_libraryAlbumsProvider.future);
|
|
},
|
|
// Same responsive 3-up grid as the artist detail
|
|
// album list — LayoutBuilder + AlbumCard sized to the
|
|
// cell, mainAxisExtent matched to actual card height.
|
|
child: LayoutBuilder(builder: (ctx, constraints) {
|
|
const cols = 3;
|
|
const sidePad = 8.0;
|
|
const gap = 8.0;
|
|
final cellW = (constraints.maxWidth -
|
|
sidePad * 2 -
|
|
gap * (cols - 1)) /
|
|
cols;
|
|
// cover (cellW - 16) + gap (8) + 2-line title (~36)
|
|
// + artist line (~16) + slack. Slack is generous on
|
|
// purpose — line-height + font scaling variations
|
|
// would otherwise overflow the cell by a pixel
|
|
// (logged as a noisy RenderFlex warning).
|
|
final cellH = (cellW - 16) + 8 + 36 + 16 + 8;
|
|
return NotificationListener<ScrollNotification>(
|
|
onNotification: (n) {
|
|
if (n.metrics.pixels >=
|
|
n.metrics.maxScrollExtent - 800) {
|
|
ref
|
|
.read(_libraryAlbumsProvider.notifier)
|
|
.loadMore();
|
|
}
|
|
return false;
|
|
},
|
|
child: GridView.builder(
|
|
padding: const EdgeInsets.all(sidePad),
|
|
gridDelegate:
|
|
SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: cols,
|
|
mainAxisExtent: cellH,
|
|
mainAxisSpacing: gap,
|
|
crossAxisSpacing: gap,
|
|
),
|
|
itemCount: page.items.length,
|
|
itemBuilder: (ctx, i) {
|
|
final album = page.items[i];
|
|
return AlbumCard(
|
|
album: album,
|
|
width: cellW,
|
|
titleMaxLines: 2,
|
|
onTap: () => ctx.push('/albums/${album.id}',
|
|
extra: album),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HistoryTab extends ConsumerWidget {
|
|
const _HistoryTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
return ref.watch(_historyProvider).when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
|
data: (page) => page.events.isEmpty
|
|
? Center(child: Text('No listening history yet.', style: TextStyle(color: fs.ash)))
|
|
: RefreshIndicator(
|
|
onRefresh: () async => ref.refresh(_historyProvider.future),
|
|
child: ListView.separated(
|
|
itemCount: page.events.length,
|
|
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
|
itemBuilder: (ctx, i) {
|
|
final event = page.events[i];
|
|
return TrackRow(
|
|
track: event.track,
|
|
onTap: () => ref
|
|
.read(playerActionsProvider)
|
|
.playTracks([event.track]),
|
|
trailing: Text(
|
|
_relativeTime(event.playedAt),
|
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _LikedTab extends ConsumerWidget {
|
|
const _LikedTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final tracksA = ref.watch(_likedTracksProvider);
|
|
final albumsA = ref.watch(_likedAlbumsProvider);
|
|
final artistsA = ref.watch(_likedArtistsProvider);
|
|
|
|
if (tracksA.isLoading || albumsA.isLoading || artistsA.isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
final t = tracksA.value;
|
|
final al = albumsA.value;
|
|
final ar = artistsA.value;
|
|
if (t == null || al == null || ar == null) {
|
|
return Center(child: Text("Couldn't load liked items.", style: TextStyle(color: fs.error)));
|
|
}
|
|
if (t.items.isEmpty && al.items.isEmpty && ar.items.isEmpty) {
|
|
return Center(child: Text('No liked artists, albums, or tracks yet.', style: TextStyle(color: fs.ash), textAlign: TextAlign.center));
|
|
}
|
|
return RefreshIndicator(
|
|
onRefresh: () async {
|
|
await Future.wait([
|
|
ref.refresh(_likedTracksProvider.future),
|
|
ref.refresh(_likedAlbumsProvider.future),
|
|
ref.refresh(_likedArtistsProvider.future),
|
|
]);
|
|
},
|
|
child: ListView(children: [
|
|
if (ar.items.isNotEmpty) ...[
|
|
_SectionHeader(label: 'Artists', count: ar.total),
|
|
SizedBox(
|
|
height: 168,
|
|
child: ListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
itemCount: ar.items.length,
|
|
itemBuilder: (ctx, i) {
|
|
final artist = ar.items[i];
|
|
return ArtistCard(
|
|
artist: artist,
|
|
onTap: () =>
|
|
ctx.push('/artists/${artist.id}', extra: artist),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
if (al.items.isNotEmpty) ...[
|
|
_SectionHeader(label: 'Albums', count: al.total),
|
|
SizedBox(
|
|
height: 200,
|
|
child: ListView.builder(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
itemCount: al.items.length,
|
|
itemBuilder: (ctx, i) {
|
|
final album = al.items[i];
|
|
return AlbumCard(
|
|
album: album,
|
|
onTap: () =>
|
|
ctx.push('/albums/${album.id}', extra: album),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
if (t.items.isNotEmpty) ...[
|
|
_SectionHeader(label: 'Tracks', count: t.total),
|
|
...t.items.asMap().entries.map((e) {
|
|
return TrackRow(
|
|
track: e.value,
|
|
onTap: () => ref
|
|
.read(playerActionsProvider)
|
|
.playTracks(t.items, initialIndex: e.key),
|
|
);
|
|
}),
|
|
],
|
|
const SizedBox(height: 96),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HiddenTab extends ConsumerWidget {
|
|
const _HiddenTab();
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
return ref.watch(myQuarantineProvider).when(
|
|
loading: () => const Center(child: CircularProgressIndicator()),
|
|
error: (e, _) => Center(child: Text('$e', style: TextStyle(color: fs.error))),
|
|
data: (rows) => rows.isEmpty
|
|
? Center(
|
|
child: Text(
|
|
'No hidden tracks.\nUse a track\'s menu to flag bad rips or wrong tags.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: fs.ash),
|
|
),
|
|
)
|
|
: RefreshIndicator(
|
|
onRefresh: () async => ref.refresh(myQuarantineProvider.future),
|
|
child: ListView.separated(
|
|
itemCount: rows.length,
|
|
separatorBuilder: (_, __) => Divider(height: 1, color: fs.iron),
|
|
itemBuilder: (ctx, i) => _QuarantineTile(row: rows[i]),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _QuarantineTile extends ConsumerWidget {
|
|
const _QuarantineTile({required this.row});
|
|
final QuarantineMineRow row;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
final reasonLabel = switch (row.reason) {
|
|
'bad_rip' => 'Bad rip',
|
|
'wrong_file' => 'Wrong file',
|
|
'wrong_tags' => 'Wrong tags',
|
|
'duplicate' => 'Duplicate',
|
|
_ => 'Other',
|
|
};
|
|
final coverUrl = row.albumId.isNotEmpty ? '/api/albums/${row.albumId}/cover' : '';
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: SizedBox(
|
|
width: 56,
|
|
height: 56,
|
|
child: coverUrl.isEmpty
|
|
? Container(color: fs.slate)
|
|
: ServerImage(
|
|
url: coverUrl,
|
|
fit: BoxFit.cover,
|
|
fallback: Container(color: fs.slate),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(
|
|
row.trackTitle,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: fs.parchment, fontSize: 14),
|
|
),
|
|
Text(
|
|
'${row.artistName} · ${row.albumTitle}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(color: fs.ash, fontSize: 12),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Row(children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: fs.iron,
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Text(reasonLabel, style: TextStyle(color: fs.ash, fontSize: 11)),
|
|
),
|
|
if (row.createdAt.isNotEmpty) ...[
|
|
const SizedBox(width: 8),
|
|
Text(_relativeTime(row.createdAt),
|
|
style: TextStyle(color: fs.ash, fontSize: 11)),
|
|
],
|
|
]),
|
|
),
|
|
]),
|
|
),
|
|
IconButton(
|
|
tooltip: 'Unhide',
|
|
icon: Icon(Icons.restore, color: fs.ash, size: 20),
|
|
onPressed: () async {
|
|
try {
|
|
await ref.read(myQuarantineProvider.notifier).unflag(row.trackId);
|
|
} catch (_) {
|
|
// Optimistic rollback handled by the notifier; surface
|
|
// the failure only if the user kept the tab open.
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Could not unhide; try again.')),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectionHeader extends StatelessWidget {
|
|
const _SectionHeader({required this.label, required this.count});
|
|
final String label;
|
|
final int count;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final fs = Theme.of(context).extension<FabledSwordTheme>()!;
|
|
return Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
|
child: Row(children: [
|
|
Text(label,
|
|
style: TextStyle(
|
|
color: fs.parchment, fontSize: 18, fontWeight: FontWeight.w500)),
|
|
const SizedBox(width: 8),
|
|
Text('$count', style: TextStyle(color: fs.ash, fontSize: 13)),
|
|
]),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Lightweight relative-time formatter: <1h "23m ago" / <24h "3h ago"
|
|
/// / <7d "Tue 14:32" / older "May 1" / older diff-year "May 1, 2025".
|
|
/// Mirrors web/src/lib/components/HistoryRow.svelte's relativeTime.
|
|
String _relativeTime(String iso) {
|
|
final t = DateTime.tryParse(iso);
|
|
if (t == null) return '';
|
|
final now = DateTime.now();
|
|
final diff = now.difference(t);
|
|
if (diff.inMinutes < 60) {
|
|
final m = diff.inMinutes < 1 ? 1 : diff.inMinutes;
|
|
return '${m}m ago';
|
|
}
|
|
if (diff.inHours < 24) return '${diff.inHours}h ago';
|
|
if (diff.inDays < 7) {
|
|
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
|
final hh = t.hour.toString().padLeft(2, '0');
|
|
final mm = t.minute.toString().padLeft(2, '0');
|
|
return '${days[t.weekday - 1]} $hh:$mm';
|
|
}
|
|
const months = [
|
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
|
];
|
|
if (t.year == now.year) return '${months[t.month - 1]} ${t.day}';
|
|
return '${months[t.month - 1]} ${t.day}, ${t.year}';
|
|
}
|