speed up series update

This commit is contained in:
2025-11-10 15:05:15 +01:00
parent 0066143712
commit d3a2618dd9
7 changed files with 616 additions and 167 deletions
+51 -17
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import '../../core/api/backend_api.dart';
import '../../core/config.dart';
import '../../core/async_utils.dart';
import '../shared/providers.dart';
class ImportScreen extends ConsumerStatefulWidget {
@@ -18,6 +20,7 @@ class _ImportScreenState extends ConsumerState<ImportScreen> {
TextEditingController(text: '1396, 1399'); // Breaking Bad, GoT
String _log = '';
bool _busy = false;
bool _cancel = false;
void _append(String msg) => setState(() => _log += '$msg\n');
@@ -44,7 +47,7 @@ class _ImportScreenState extends ConsumerState<ImportScreen> {
}
Future<void> _importShows() async {
setState(() => _busy = true);
setState(() { _busy = true; _cancel = false; });
final tmdb = ref.read(tmdbApiProvider);
final backend = ref.read(backendApiProvider);
@@ -54,6 +57,7 @@ class _ImportScreenState extends ConsumerState<ImportScreen> {
.map(int.parse);
for (final showId in ids) {
if (_cancel) break;
try {
_append('Serie $showId: TMDB laden …');
final showJson = await tmdb.getShow(showId);
@@ -62,27 +66,51 @@ class _ImportScreenState extends ConsumerState<ImportScreen> {
await backend.upsertShow(showJson);
_append('Serie $showId: Show OK ✓');
final seasons = (showJson['seasons'] as List? ?? const [])
final seasonNos = (showJson['seasons'] as List? ?? const [])
.where((s) => (s['season_number'] ?? 0) is int)
.cast<Map<String, dynamic>>();
.map((s) => (s as Map<String, dynamic>)['season_number'] as int)
.where((n) => n >= 0)
.toList();
for (final s in seasons) {
final seasonNo = (s['season_number'] as num).toInt();
if (seasonNo < 0) continue;
_append(' S$seasonNo: TMDB Season laden …');
// Fetch all seasons (limited concurrency)
final seasonsJson = <int, Map<String, dynamic>>{};
await runChunked<int>(
seasonNos,
AppConfig.tmdbSeasonFetchConcurrency,
(sNo) async {
if (_cancel) return;
final sj = await tmdb.getSeason(showId, sNo);
seasonsJson[sNo] = sj;
},
isCancelled: () => _cancel,
);
final seasonJson = await tmdb.getSeason(showId, seasonNo);
final dbShowId = await _getDbShowIdByTmdb(backend, showId);
final dbSeasonId = await backend.upsertSeason(dbShowId, seasonJson);
final dbShowId = await _getDbShowIdByTmdb(backend, showId);
// Bulk upsert seasons and get IDs
Map<int,int> seasonIds = const {};
try {
seasonIds = await backend.upsertSeasonsBulk(dbShowId, seasonsJson.values.toList());
} catch (_) {
final m = <int,int>{};
for (final entry in seasonsJson.entries) { final id = await backend.upsertSeason(dbShowId, entry.value); m[entry.key] = id; }
seasonIds = m;
}
_append(' S$seasonNo: Season OK (db:$dbSeasonId)');
final eps = (seasonJson['episodes'] as List? ?? const [])
.cast<Map<String, dynamic>>();
for (final e in eps) {
await backend.upsertEpisode(dbSeasonId, e);
for (final entry in seasonsJson.entries) {
if (_cancel) break;
final sNo = entry.key; final seasonJson = entry.value; final dbSeasonId = seasonIds[sNo]; if (dbSeasonId == null) continue;
_append(' S$sNo: upserting episodes …');
final eps = (seasonJson['episodes'] as List? ?? const []).cast<Map<String, dynamic>>();
try { await backend.upsertEpisodesBulk(dbSeasonId, eps); }
catch (_) {
await runChunked<Map<String, dynamic>>(
eps,
AppConfig.dbEpisodeUpsertConcurrency,
(e) async { if (_cancel) return; await backend.upsertEpisode(dbSeasonId, e); },
isCancelled: () => _cancel,
);
}
_append(' S$seasonNo: ${eps.length} Episoden OK ✓');
_append(' S$sNo: ${eps.length} Episoden OK ✓');
}
} catch (e) {
// 👇 Hier kommt der erweiterte Catch hin!
@@ -139,6 +167,12 @@ class _ImportScreenState extends ConsumerState<ImportScreen> {
onPressed: _busy ? null : _importShows,
child: const Text('Import Serien'),
),
const SizedBox(width: 8),
if (_busy)
TextButton(
onPressed: () => setState(() => _cancel = true),
child: const Text('Abbrechen'),
),
],
),
const SizedBox(height: 12),
@@ -7,6 +7,8 @@ import '../../../core/status.dart';
import '../../shared/providers.dart';
import '../data/series_repository.dart';
import '../data/episode_model.dart';
import '../../../core/async_utils.dart';
import '../../../core/config.dart';
class SeriesDetailScreen extends ConsumerStatefulWidget {
// showName is the grouping key (unique).
@@ -278,64 +280,126 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
? null
: () async {
final messenger = ScaffoldMessenger.of(context);
try {
final tmdb = ref.read(tmdbApiProvider);
final backend = ref.read(backendApiProvider);
int? tid = _tmdbId;
if (tid == null && _showId != null) {
tid = await backend.getTmdbIdByShowId(_showId!);
}
if (tid == null) {
throw 'tmdb_id für diese Serie konnte nicht ermittelt werden';
}
final showJson = await tmdb.getShow(tid);
// Update header meta immediately (overview/cast/crew)
try {
setState(() {
_overview = (showJson['overview'] as String?) ?? _overview;
final credits = showJson['credits'] as Map<String, dynamic>?;
if (credits != null) {
final c = (credits['cast'] as List? ?? const [])
.cast()
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
final cr = (credits['crew'] as List? ?? const [])
.cast()
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
_cast = c.take(12).toList();
_crew = cr.take(12).toList();
int done = 0;
int total = 0;
var cancel = false;
await showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) {
return StatefulBuilder(builder: (ctx, setState) {
Future<void> run() async {
try {
final tmdb = ref.read(tmdbApiProvider);
final backend = ref.read(backendApiProvider);
int? tid = _tmdbId;
if (tid == null && _showId != null) {
tid = await backend.getTmdbIdByShowId(_showId!);
}
if (tid == null) {
throw 'tmdb_id für diese Serie konnte nicht ermittelt werden';
}
final int tidVal = tid; // non-null now
final showJson = await tmdb.getShow(tidVal);
// Update header meta immediately (overview/cast/crew)
try {
if (mounted) {
setState(() {
_overview = (showJson['overview'] as String?) ?? _overview;
final credits = showJson['credits'] as Map<String, dynamic>?;
if (credits != null) {
final c = (credits['cast'] as List? ?? const [])
.cast()
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
final cr = (credits['crew'] as List? ?? const [])
.cast()
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
_cast = c.take(12).toList();
_crew = cr.take(12).toList();
}
});
}
} catch (_) {}
final dbShowId = await backend.upsertShow(showJson);
// seasons
final sNos = (showJson['seasons'] as List? ?? const [])
.where((s) => (s['season_number'] ?? -1) is num)
.map((s) => (s as Map<String, dynamic>)['season_number'] as int)
.where((s) => s >= 0)
.toList();
final seasonsJson = <int, Map<String, dynamic>>{};
await runChunked<int>(
sNos,
AppConfig.tmdbSeasonFetchConcurrency,
(sNo) async { if (cancel) return; final sj = await tmdb.getSeason(tidVal, sNo); seasonsJson[sNo] = sj; },
isCancelled: () => cancel,
);
Map<int,int> seasonIds = const {};
try {
seasonIds = await backend.upsertSeasonsBulk(dbShowId, seasonsJson.values.toList());
} catch (_) {
final m = <int,int>{};
for (final entry in seasonsJson.entries) { final id = await backend.upsertSeason(dbShowId, entry.value); m[entry.key] = id; }
seasonIds = m;
}
total = 0; for (final sj in seasonsJson.values) { total += ((sj['episodes'] as List? ?? const []).length); }
if (ctx.mounted) setState(() {});
for (final entry in seasonsJson.entries) {
if (cancel) break;
final sNo = entry.key; final seasonJson = entry.value; final seasonId = seasonIds[sNo]; if (seasonId == null) continue;
final eps = (seasonJson['episodes'] as List? ?? const []).cast<Map<String, dynamic>>();
try { await backend.upsertEpisodesBulk(seasonId, eps); done += eps.length; if (ctx.mounted) setState(() {}); }
catch (_) {
await runChunked<Map<String, dynamic>>(
eps,
AppConfig.dbEpisodeUpsertConcurrency,
(e) async { if (cancel) return; await backend.upsertEpisode(seasonId, e); done++; if (ctx.mounted) setState(() {}); },
isCancelled: () => cancel,
);
}
}
// Refresh list + reload local grouped data
// ignore: unused_result
ref.invalidate(seriesGroupedProvider);
await _load();
messenger.showSnackBar(
const SnackBar(content: Text('TMDB Daten aktualisiert')),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('TMDB Update fehlgeschlagen: $e')),
);
} finally {
if (ctx.mounted) Navigator.of(ctx).pop();
}
}
if (done == 0 && total == 0) {
// ignore: discarded_futures
run();
}
return AlertDialog(
title: const Text('TMDB Update'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(total > 0 ? '$done / $total Episoden' : 'Lade …'),
const SizedBox(height: 12),
LinearProgressIndicator(value: total > 0 ? (done / total).clamp(0, 1) : null),
],
),
actions: [
TextButton(
onPressed: () { cancel = true; },
child: const Text('Abbrechen'),
),
],
);
});
} catch (_) {}
final dbShowId = await backend.upsertShow(showJson);
// seasons
final seasons = (showJson['seasons'] as List? ?? const [])
.where((s) => (s['season_number'] ?? -1) is num)
.map((s) => (s as Map<String, dynamic>)['season_number'] as int)
.toList();
for (final sNo in seasons) {
if (sNo < 0) continue;
final seasonJson = await tmdb.getSeason(tid, sNo);
final seasonId = await backend.upsertSeason(dbShowId, seasonJson);
final eps = (seasonJson['episodes'] as List? ?? const [])
.cast<Map<String, dynamic>>();
for (final e in eps) {
await backend.upsertEpisode(seasonId, e);
}
}
// Refresh list + reload local grouped data
// ignore: unused_result
ref.invalidate(seriesGroupedProvider);
await _load();
messenger.showSnackBar(
const SnackBar(content: Text('TMDB Daten aktualisiert')),
);
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('TMDB Update fehlgeschlagen: $e')),
);
}
},
);
},
),
if (dirty)
@@ -13,12 +13,29 @@ import '../../shared/providers.dart';
import '../data/episode_model.dart';
import 'widgets/season_status_bar.dart';
import '../../../core/io_open.dart';
import '../../../core/config.dart';
import '../../../core/async_utils.dart';
class SeriesListScreen extends ConsumerWidget {
class SeriesListScreen extends ConsumerStatefulWidget {
const SeriesListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<SeriesListScreen> createState() => _SeriesListScreenState();
}
class _SeriesListScreenState extends ConsumerState<SeriesListScreen> {
final ScrollController _groupedVController = ScrollController();
final ScrollController _summaryVController = ScrollController();
@override
void dispose() {
_groupedVController.dispose();
_summaryVController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final filter = ref.watch(episodeFilterProvider);
final groupedAsync = ref.watch(seriesGroupedProvider);
final summaryAsync = ref.watch(seriesSummaryProvider);
@@ -60,6 +77,7 @@ class SeriesListScreen extends ConsumerWidget {
String current = '';
int idx = 0;
int total = 0;
var cancel = false;
await showDialog(
context: context,
barrierDismissible: false,
@@ -88,46 +106,78 @@ class SeriesListScreen extends ConsumerWidget {
final t = (m['id'] as num?)?.toInt();
if (t != null) {
showRows.add(
{'tmdb_id': t, 'name': entry.key});
{'tmdb_id': t, 'name': entry.key.split('##').first});
}
} catch (_) {}
}
}
// Sort by display name (before any '##') for a stable, predictable order
showRows.sort((a, b) {
String an = (a['name'] as String? ?? '').split('##').first.toLowerCase();
String bn = (b['name'] as String? ?? '').split('##').first.toLowerCase();
return an.compareTo(bn);
});
total = showRows.length;
int success = 0;
for (final row in showRows) {
if (cancel) break;
idx++;
final tmdbId =
(row['tmdb_id'] as num?)?.toInt();
current = (row['name'] as String?) ??
'tmdb:$tmdbId';
setState(() {});
current = (row['name'] as String?) ?? 'tmdb:$tmdbId';
if (ctx.mounted) setState(() {});
if (tmdbId == null) continue;
try {
final showJson = await tmdb.getShow(tmdbId);
final dbShowId =
await backend.upsertShow(showJson);
final seasons = (showJson['seasons']
as List? ??
const [])
.where((s) =>
(s['season_number'] ?? -1) is num)
.map((s) => (s as Map<String, dynamic>)[
'season_number'] as int)
final sNos = (showJson['seasons'] as List? ?? const [])
.where((s) => (s['season_number'] ?? -1) is num)
.map((s) => (s as Map<String, dynamic>)['season_number'] as int)
.where((s) => s >= 0)
.toList();
for (final sNo in seasons) {
if (sNo < 0) continue;
final seasonJson =
await tmdb.getSeason(tmdbId, sNo);
final seasonId = await backend
.upsertSeason(dbShowId, seasonJson);
final eps =
(seasonJson['episodes'] as List? ??
const [])
.cast<Map<String, dynamic>>();
for (final e in eps) {
await backend.upsertEpisode(
seasonId, e);
// Fetch all seasons (limited concurrency)
final seasonsJson = <int, Map<String, dynamic>>{};
await runChunked<int>(
sNos,
AppConfig.tmdbSeasonFetchConcurrency,
(sNo) async {
if (cancel) return;
final sj = await tmdb.getSeason(tmdbId, sNo);
seasonsJson[sNo] = sj;
},
isCancelled: () => cancel,
);
// Bulk upsert seasons and get IDs
Map<int,int> seasonIds = const {};
try {
seasonIds = await backend.upsertSeasonsBulk(dbShowId, seasonsJson.values.toList());
} catch (_) {
// Fallback per season
final m = <int,int>{};
for (final entry in seasonsJson.entries) {
final id = await backend.upsertSeason(dbShowId, entry.value);
m[entry.key] = id;
}
seasonIds = m;
}
// Upsert episodes per season (prefer bulk)
for (final entry in seasonsJson.entries) {
if (cancel) break;
final sNo = entry.key;
final seasonJson = entry.value;
final seasonId = seasonIds[sNo];
if (seasonId == null) continue;
final eps = (seasonJson['episodes'] as List? ?? const [])
.cast<Map<String, dynamic>>();
try { await backend.upsertEpisodesBulk(seasonId, eps); }
catch (_) {
await runChunked<Map<String, dynamic>>(
eps,
AppConfig.dbEpisodeUpsertConcurrency,
(e) async { if (cancel) return; await backend.upsertEpisode(seasonId, e); },
isCancelled: () => cancel,
);
}
}
success++;
@@ -135,6 +185,8 @@ class SeriesListScreen extends ConsumerWidget {
}
// ignore: unused_result
ref.invalidate(seriesGroupedProvider);
// ignore: unused_result
ref.invalidate(seriesSummaryProvider);
messenger.showSnackBar(
SnackBar(
content: Text(
@@ -166,6 +218,14 @@ class SeriesListScreen extends ConsumerWidget {
value: total > 0 ? idx / total : null),
],
),
actions: [
TextButton(
onPressed: () {
cancel = true;
},
child: const Text('Abbrechen'),
),
],
);
});
},
@@ -324,7 +384,7 @@ class SeriesListScreen extends ConsumerWidget {
final cells = <DataCell>[
DataCell(
MenuAnchor(
menuChildren: [..._seriesMenuItems(context, ref, showKey, displayName, downloadPath),
menuChildren: [...widget._seriesMenuItems(context, ref, showKey, displayName, downloadPath),
MenuItemButton(
child: const Text('set all progress to done'),
onPressed: () async {
@@ -365,37 +425,101 @@ class SeriesListScreen extends ConsumerWidget {
messenger.showSnackBar(const SnackBar(content: Text('Unbekannte Show-ID')));
return;
}
try {
final tmdb = ref.read(tmdbApiProvider);
final backend = ref.read(backendApiProvider);
final tid = await backend.getTmdbIdByShowId(sid);
if (tid == null) {
messenger.showSnackBar(const SnackBar(content: Text('Keine TMDB-ID gefunden')));
return;
}
final showJson = await tmdb.getShow(tid);
final dbShowId = await backend.upsertShow(showJson);
final seasons = (showJson['seasons'] as List? ?? const [])
.where((s) => (s['season_number'] ?? -1) is num)
.map((s) => (s as Map<String, dynamic>)['season_number'] as int)
.toList();
for (final sNo in seasons) {
if (sNo < 0) continue;
final seasonJson = await tmdb.getSeason(tid, sNo);
final seasonId = await backend.upsertSeason(dbShowId, seasonJson);
final eps = (seasonJson['episodes'] as List? ?? const []).cast<Map<String, dynamic>>();
for (final e in eps) {
await backend.upsertEpisode(seasonId, e);
}
}
// ignore: unused_result
ref.invalidate(seriesGroupedProvider);
// ignore: unused_result
ref.invalidate(seriesSummaryProvider);
messenger.showSnackBar(const SnackBar(content: Text('Update abgeschlossen')));
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Update fehlgeschlagen: $e')));
}
String current = displayName;
int done = 0;
int total = 0;
var cancel = false;
await showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) {
return StatefulBuilder(builder: (ctx, setState) {
Future<void> run() async {
try {
final tmdb = ref.read(tmdbApiProvider);
final backend = ref.read(backendApiProvider);
final tid = await backend.getTmdbIdByShowId(sid);
if (tid == null) { messenger.showSnackBar(const SnackBar(content: Text('Keine TMDB-ID gefunden'))); return; }
final showJson = await tmdb.getShow(tid);
final dbShowId = await backend.upsertShow(showJson);
final sNos = (showJson['seasons'] as List? ?? const [])
.where((s) => (s['season_number'] ?? -1) is num)
.map((s) => (s as Map<String, dynamic>)['season_number'] as int)
.where((s) => s >= 0)
.toList();
final seasonsJson = <int, Map<String, dynamic>>{};
await runChunked<int>(
sNos,
AppConfig.tmdbSeasonFetchConcurrency,
(sNo) async {
if (cancel) return;
final sj = await tmdb.getSeason(tid, sNo);
seasonsJson[sNo] = sj;
},
isCancelled: () => cancel,
);
Map<int,int> seasonIds = const {};
try {
seasonIds = await backend.upsertSeasonsBulk(dbShowId, seasonsJson.values.toList());
} catch (_) {
final m = <int,int>{};
for (final entry in seasonsJson.entries) {
final id = await backend.upsertSeason(dbShowId, entry.value);
m[entry.key] = id;
}
seasonIds = m;
}
// Count total episodes
total = 0; for (final sj in seasonsJson.values) { total += ((sj['episodes'] as List? ?? const []).length); }
if (ctx.mounted) setState(() {});
for (final entry in seasonsJson.entries) {
if (cancel) break;
final sNo = entry.key; final seasonJson = entry.value; final seasonId = seasonIds[sNo]; if (seasonId == null) continue;
final eps = (seasonJson['episodes'] as List? ?? const []).cast<Map<String, dynamic>>();
try { await backend.upsertEpisodesBulk(seasonId, eps); done += eps.length; if (ctx.mounted) setState(() {}); }
catch (_) {
await runChunked<Map<String, dynamic>>(
eps,
AppConfig.dbEpisodeUpsertConcurrency,
(e) async { if (cancel) return; await backend.upsertEpisode(seasonId, e); done++; if (ctx.mounted) setState(() {}); },
isCancelled: () => cancel,
);
}
}
ref.invalidate(seriesGroupedProvider);
ref.invalidate(seriesSummaryProvider);
messenger.showSnackBar(const SnackBar(content: Text('Update abgeschlossen')));
} catch (e) {
messenger.showSnackBar(SnackBar(content: Text('Update fehlgeschlagen: $e')));
} finally {
if (ctx.mounted) Navigator.of(ctx).pop();
}
}
if (done == 0 && total == 0) {
// ignore: discarded_futures
run();
}
return AlertDialog(
title: Text('Update: $current'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(total > 0 ? '$done / $total Episoden' : 'Lade …'),
const widgets.SizedBox(height: 12),
LinearProgressIndicator(value: total > 0 ? (done / total).clamp(0, 1) : null),
],
),
actions: [
TextButton(
onPressed: () { cancel = true; },
child: const Text('Abbrechen'),
),
],
);
});
},
);
},
),
MenuItemButton(
@@ -495,7 +619,7 @@ class SeriesListScreen extends ConsumerWidget {
if (eps == null || eps.isEmpty) {
cells.add(DataCell(
MenuAnchor(
menuChildren: [..._seriesMenuItems(context, ref, showKey, displayName, downloadPath),
menuChildren: [...widget._seriesMenuItems(context, ref, showKey, displayName, downloadPath),
MenuItemButton(
child: const Text('set all progress to done'),
onPressed: () async {
@@ -587,7 +711,7 @@ class SeriesListScreen extends ConsumerWidget {
} else {
cells.add(DataCell(
MenuAnchor(
menuChildren: [..._seriesMenuItems(context, ref, showKey, displayName, downloadPath),
menuChildren: [...widget._seriesMenuItems(context, ref, showKey, displayName, downloadPath),
MenuItemButton(
child: const Text('set all progress to done'),
onPressed: () async {
@@ -687,7 +811,21 @@ class SeriesListScreen extends ConsumerWidget {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SingleChildScrollView(child: table),
child: RawScrollbar(
controller: _groupedVController,
thumbVisibility: true,
trackVisibility: true,
interactive: true,
thickness: 10,
radius: const Radius.circular(6),
thumbColor: Colors.black54,
trackColor: Colors.black12,
trackBorderColor: Colors.black26,
child: SingleChildScrollView(
controller: _groupedVController,
child: table,
),
),
);
},
);
@@ -703,7 +841,7 @@ class SeriesListScreen extends ConsumerWidget {
BuildContext context, WidgetRef ref, List<Map<String, dynamic>> items) {
final filter = ref.watch(episodeFilterProvider);
bool _includeByFilter(Map<String, dynamic> m) {
bool includeByFilter(Map<String, dynamic> m) {
int initTotal = 0;
int progTotal = 0;
int doneTotal = 0;
@@ -766,7 +904,7 @@ class SeriesListScreen extends ConsumerWidget {
}
// Apply series-level filter
items = items.where(_includeByFilter).toList();
items = items.where(includeByFilter).toList();
// Build and sort keys
final keys = <String>[];
final byKey = <String, Map<String, dynamic>>{};
@@ -969,7 +1107,7 @@ class SeriesListScreen extends ConsumerWidget {
children: [
// Left (with context menu)
MenuAnchor(
menuChildren: _seriesMenuItems(context, ref, rowKey, name, downloadPath),
menuChildren: widget._seriesMenuItems(context, ref, rowKey, name, downloadPath),
builder: (ctx, controller, child) => GestureDetector(
onSecondaryTapDown: (_) => controller.open(),
onSecondaryTapUp: (_) => controller.open(),
@@ -1059,7 +1197,7 @@ class SeriesListScreen extends ConsumerWidget {
}
if (eps.isNotEmpty) {
return MenuAnchor(
menuChildren: [..._seriesMenuItems(context, ref, rowKey, name, downloadPath),
menuChildren: [...widget._seriesMenuItems(context, ref, rowKey, name, downloadPath),
MenuItemButton(
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
@@ -1108,7 +1246,7 @@ class SeriesListScreen extends ConsumerWidget {
);
if (match.isEmpty) {
return MenuAnchor(
menuChildren: [..._seriesMenuItems(context, ref, rowKey, name, downloadPath),
menuChildren: [...widget._seriesMenuItems(context, ref, rowKey, name, downloadPath),
MenuItemButton(
onPressed: () async {
final messenger = ScaffoldMessenger.of(context);
@@ -1195,29 +1333,38 @@ class SeriesListScreen extends ConsumerWidget {
}
const headerHeight = 32.0;
return Scrollbar(
thumbVisibility: true,
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: widgets.SizedBox(
width: tableWidth,
height: constraints.maxHeight,
child: Stack(
children: [
// Sticky header at the top; scrolls horizontally with content
Positioned(
top: 0,
left: 0,
right: 0,
child: widgets.SizedBox(
height: headerHeight, child: buildHeader()),
),
// Vertical list below the header
Positioned.fill(
top: headerHeight,
return LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: widgets.SizedBox(
width: tableWidth,
height: constraints.maxHeight,
child: Stack(
children: [
// Sticky header at the top; scrolls horizontally with content
Positioned(
top: 0,
left: 0,
right: 0,
child:
widgets.SizedBox(height: headerHeight, child: buildHeader()),
),
// Vertical list below the header with its own scrollbar
Positioned.fill(
top: headerHeight,
child: RawScrollbar(
controller: _summaryVController,
thumbVisibility: true,
trackVisibility: true,
interactive: true,
thickness: 10,
radius: const Radius.circular(6),
thumbColor: Colors.black54,
trackColor: Colors.black12,
trackBorderColor: Colors.black26,
child: ListView.separated(
controller: _summaryVController,
itemCount: keys.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) => widgets.SizedBox(
@@ -1226,12 +1373,12 @@ class SeriesListScreen extends ConsumerWidget {
),
),
),
],
),
),
],
),
);
},
),
),
);
},
);
}