|
|
|
|
@ -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 {
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|