chore: init repository with project sources

This commit is contained in:
2025-10-30 10:29:33 +01:00
commit 65f1175ba7
97 changed files with 7762 additions and 0 deletions
+172
View File
@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:dio/dio.dart';
import '../../core/api/tmdb_api.dart';
import '../../core/api/backend_api.dart';
import '../shared/providers.dart';
class ImportScreen extends ConsumerStatefulWidget {
const ImportScreen({super.key});
@override
ConsumerState<ImportScreen> createState() => _ImportScreenState();
}
class _ImportScreenState extends ConsumerState<ImportScreen> {
final _movieCtrl =
TextEditingController(text: '603, 27205'); // Matrix, Inception
final _showCtrl =
TextEditingController(text: '1396, 1399'); // Breaking Bad, GoT
String _log = '';
bool _busy = false;
void _append(String msg) => setState(() => _log += msg + '\n');
Future<void> _importMovies() async {
setState(() => _busy = true);
final tmdb = ref.read(tmdbApiProvider);
final backend = ref.read(backendApiProvider);
final ids = _movieCtrl.text
.split(RegExp(r'[,\s]+'))
.where((s) => s.isNotEmpty)
.map(int.parse);
for (final id in ids) {
try {
_append('Film $id: TMDB laden …');
final json = await tmdb.getMovie(id);
await backend.upsertMovie(json);
_append('Film $id: OK ✓');
} catch (e) {
_append('Film $id: Fehler → $e');
}
}
setState(() => _busy = false);
}
Future<void> _importShows() async {
setState(() => _busy = true);
final tmdb = ref.read(tmdbApiProvider);
final backend = ref.read(backendApiProvider);
final ids = _showCtrl.text
.split(RegExp(r'[,\s]+'))
.where((s) => s.isNotEmpty)
.map(int.parse);
for (final showId in ids) {
try {
_append('Serie $showId: TMDB laden …');
final showJson = await tmdb.getShow(showId);
print('SHOW JSON: $showJson'); // Debug-Ausgabe
await backend.upsertShow(showJson);
_append('Serie $showId: Show OK ✓');
final seasons = (showJson['seasons'] as List? ?? const [])
.where((s) => (s['season_number'] ?? 0) is int)
.cast<Map<String, dynamic>>();
for (final s in seasons) {
final seasonNo = (s['season_number'] as num).toInt();
if (seasonNo < 0) continue;
_append(' S$seasonNo: TMDB Season laden …');
final seasonJson = await tmdb.getSeason(showId, seasonNo);
final dbShowId = await _getDbShowIdByTmdb(backend, showId);
final dbSeasonId = await backend.upsertSeason(dbShowId, seasonJson);
_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);
}
_append(' S$seasonNo: ${eps.length} Episoden OK ✓');
}
} catch (e) {
// 👇 Hier kommt der erweiterte Catch hin!
if (e is DioException) {
print('❗ TMDB DioException für $showId');
print('➡️ Request: ${e.requestOptions.uri}');
print('➡️ Response: ${e.response?.data}');
print('➡️ Status: ${e.response?.statusCode}');
}
_append('Serie $showId: Fehler → $e');
}
}
setState(() => _busy = false);
}
Future<int> _getDbShowIdByTmdb(BackendApi backend, int tmdbId) async {
final id = await backend.getShowDbIdByTmdbId(tmdbId);
if (id == null) {
throw Exception(
'Show mit tmdb_id=$tmdbId nicht gefunden zuerst upsert_show aufrufen.');
}
return id;
}
@override
Widget build(BuildContext context) {
final inputStyle = const TextStyle(fontSize: 13);
return Scaffold(
appBar: AppBar(title: const Text('Import (TMDB → DB)')),
body: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
Row(
children: [
const Text('Filme TMDB-IDs: '),
const SizedBox(width: 8),
Expanded(
child:
TextField(controller: _movieCtrl, style: inputStyle)),
const SizedBox(width: 8),
FilledButton(
onPressed: _busy ? null : _importMovies,
child: const Text('Import Filme'),
),
],
),
const SizedBox(height: 12),
Row(
children: [
const Text('Serien TMDB-IDs: '),
const SizedBox(width: 8),
Expanded(
child: TextField(controller: _showCtrl, style: inputStyle)),
const SizedBox(width: 8),
FilledButton(
onPressed: _busy ? null : _importShows,
child: const Text('Import Serien'),
),
],
),
const SizedBox(height: 12),
if (_busy) const LinearProgressIndicator(),
const SizedBox(height: 12),
Expanded(
child: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
border: Border.all(color: Colors.black12),
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.topLeft,
child: SingleChildScrollView(
child: SelectableText(_log,
style: const TextStyle(
fontFamily: 'monospace', fontSize: 12)),
),
),
),
],
),
),
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import 'dart:convert';
import '../../../core/status.dart';
class Movie {
final int id; // DB-ID
final int tmdbId;
final String title;
final int? releaseYear;
final String? posterPath;
final ItemStatus status;
final String? resolution;
final String? overview;
Movie({
required this.id,
required this.tmdbId,
required this.title,
this.releaseYear,
this.posterPath,
this.status = ItemStatus.Init,
this.resolution,
this.overview,
});
factory Movie.fromJson(Map<String, dynamic> j) {
String? ov;
if (j['overview'] is String) {
ov = j['overview'] as String?;
} else if (j['json'] is String) {
try {
final m = jsonDecode(j['json'] as String);
if (m is Map && m['overview'] is String) ov = m['overview'] as String;
} catch (_) {}
}
return Movie(
id: j['id'] as int,
tmdbId: j['tmdb_id'] as int,
title: j['title'] as String,
releaseYear: j['release_year'] as int?,
posterPath: j['poster_path'] as String?,
status: j['status'] != null ? statusFromString(j['status'] as String) : ItemStatus.Init,
resolution: j['resolution'] as String?,
overview: ov,
);
}
}
@@ -0,0 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/status.dart';
import '../../shared/providers.dart';
import 'movie_model.dart';
final movieFilterProvider = StateProvider<ItemStatus?>((_) => null);
final moviesProvider = FutureProvider.autoDispose<List<Movie>>((ref) async {
final backend = ref.watch(backendApiProvider);
final st = ref.watch(movieFilterProvider);
final list = await backend.getMovies(status: st?.name);
return list.map(Movie.fromJson).toList();
});
@@ -0,0 +1,167 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/status.dart';
import '../data/movie_repository.dart';
import 'widgets/status_chip.dart';
class MovieListScreen extends ConsumerWidget {
const MovieListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final filter = ref.watch(movieFilterProvider);
final moviesAsync = ref.watch(moviesProvider);
return Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StatusChip(
selected: filter,
onChanged: (f) => ref.read(movieFilterProvider.notifier).state = f,
),
const SizedBox(height: 12),
Expanded(
child: moviesAsync.when(
data: (items) {
final itemsSorted = [...items]
..sort((a, b) => a.title.toLowerCase().compareTo(b.title.toLowerCase()));
return RefreshIndicator(
onRefresh: () async => ref.invalidate(moviesProvider),
child: ListView.separated(
itemCount: itemsSorted.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final m = itemsSorted[i];
return ListTile(
tileColor: _statusBg(m.status, context),
leading: m.posterPath != null
? ClipRRect(
borderRadius: BorderRadius.circular(6),
child: CachedNetworkImage(
imageUrl:
'https://image.tmdb.org/t/p/w154${m.posterPath}',
width: 50,
height: 75,
fit: BoxFit.cover,
),
)
: const SizedBox(width: 50, height: 75),
title: Text(
m.releaseYear != null
? '${m.title} (${m.releaseYear})'
: m.title,
overflow: TextOverflow.ellipsis,
),
subtitle: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 56,
child: Align(
alignment: Alignment.topLeft,
child: FittedBox(
alignment: Alignment.topLeft,
fit: BoxFit.scaleDown,
child: _resolutionBadge(m.resolution, context),
),
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
m.overview ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
isThreeLine: true,
onTap: () {
// TODO: Detailseite / Status ändern
},
);
},
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, st) => Center(
child: Text('Fehler: ${e.toString()}'),
),
),
),
],
),
);
}
IconData _statusIcon(ItemStatus s) {
switch (s) {
case ItemStatus.Init:
return Icons.hourglass_empty;
case ItemStatus.Progress:
return Icons.downloading;
case ItemStatus.Done:
return Icons.check_circle;
}
}
Color? _statusBg(ItemStatus s, BuildContext context) {
switch (s) {
case ItemStatus.Init:
return Colors.grey.shade200;
case ItemStatus.Progress:
return Colors.blue;
case ItemStatus.Done:
return Colors.green;
}
}
Widget _resolutionBadge(String? res, BuildContext context) {
if (res == null || res.isEmpty) return const SizedBox.shrink();
final m = RegExp(r"\d+").firstMatch(res);
final v = m != null ? int.tryParse(m.group(0)!) : null;
IconData? icon;
Color color = Theme.of(context).colorScheme.primary;
String label = res;
if (v != null) {
if (v >= 2000) {
icon = Icons.four_k;
color = Colors.deepOrange;
label = '${v}p';
} else if (v >= 1000) {
icon = Icons.hd;
color = Colors.blue;
label = '${v}p';
} else {
icon = Icons.sd_card;
color = Colors.grey;
label = '${v}p';
}
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withOpacity(0.9),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withOpacity(0.5)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) Icon(icon, size: 14, color: color),
if (icon != null) const SizedBox(width: 4),
Text(
label,
style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurface),
),
],
),
);
}
}
@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:multimediaFlutter/core/status.dart';
class StatusChip extends StatelessWidget {
final ItemStatus? selected;
final ValueChanged<ItemStatus?> onChanged;
const StatusChip(
{super.key, required this.selected, required this.onChanged});
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
children: [
FilterChip(
label: const Text('Alle'),
selected: selected == null,
onSelected: (_) => onChanged(null),
),
for (final s in ItemStatus.values)
FilterChip(
label: Text(s.name),
selected: selected == s,
onSelected: (_) => onChanged(s),
),
],
);
}
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:dio/dio.dart';
import '../../core/config.dart';
class PingTestScreen extends StatefulWidget {
const PingTestScreen({super.key});
@override
State<PingTestScreen> createState() => _PingTestScreenState();
}
class _PingTestScreenState extends State<PingTestScreen> {
String? _result;
bool _loading = false;
Future<void> _pingServer() async {
setState(() {
_loading = true;
_result = null;
});
try {
final res = await Dio(BaseOptions(
baseUrl: AppConfig.backendBaseUrl,
headers: const {
'Accept': 'application/json',
},
contentType: Headers.jsonContentType,
validateStatus: (s) => s != null && s < 500,
)).post('', data: {'action': 'ping'});
setState(() {
_result = '✅ Antwort: ${res.data}';
});
} catch (e) {
setState(() {
_result = '❌ Fehler: $e';
});
} finally {
setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Ping-Test')),
body: Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton.icon(
icon: const Icon(Icons.wifi_tethering),
label: const Text('Ping-Server'),
onPressed: _loading ? null : _pingServer,
),
const SizedBox(height: 20),
if (_loading) const CircularProgressIndicator(),
if (_result != null)
SelectableText(
_result!,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 14),
),
],
),
),
),
);
}
}
@@ -0,0 +1,57 @@
import '../../../core/status.dart';
class EpisodeItem {
final int id;
final int episodeNumber;
final int seasonNumber;
final String showName;
final String? name;
final ItemStatus status;
final String? resolution;
final int? firstAirYear;
final String? showJson;
final String? showStatus;
final bool? showCliffhanger;
final String? posterPath;
EpisodeItem({
required this.id,
required this.episodeNumber,
required this.seasonNumber,
required this.showName,
this.name,
required this.status,
this.resolution,
this.firstAirYear,
this.showJson,
this.showStatus,
this.showCliffhanger,
this.posterPath,
});
factory EpisodeItem.fromJson(Map<String, dynamic> j) => EpisodeItem(
id: j['id'] as int,
episodeNumber: j['episode_number'] as int,
seasonNumber: j['season_number'] as int,
showName: j['show_name'] as String,
name: j['name'] as String?,
status: statusFromString(j['status'] as String?),
resolution: j['resolution'] as String?,
firstAirYear: j['first_air_year'] as int?,
showJson: j['show_json'] as String?,
showStatus: j['show_status'] as String?,
showCliffhanger: _parseBool(j['show_cliffhanger']),
posterPath: j['poster_path'] as String?,
);
static bool? _parseBool(dynamic v) {
if (v == null) return null;
if (v is bool) return v;
if (v is num) return v != 0;
if (v is String) {
final s = v.toLowerCase();
return s == '1' || s == 'true' || s == 'yes';
}
return null;
}
}
@@ -0,0 +1,40 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../shared/providers.dart';
import '../../../core/status.dart';
import 'episode_model.dart';
final episodeFilterProvider = StateProvider<ItemStatus?>((_) => null);
final seriesGroupedProvider =
FutureProvider.autoDispose<SeriesGroupedData>((ref) async {
final backend = ref.watch(backendApiProvider);
final st = ref.watch(episodeFilterProvider);
final rows = await backend.getEpisodes(status: st?.name, limit: 5000);
final items = rows
.map(EpisodeItem.fromJson)
.where((e) => e.seasonNumber > 0)
.toList();
return SeriesGroupedData.fromEpisodes(items);
});
class SeriesGroupedData {
final Map<String, Map<int, List<EpisodeItem>>>
data; // showName -> season -> episodes
SeriesGroupedData(this.data);
factory SeriesGroupedData.fromEpisodes(List<EpisodeItem> items) {
final map = <String, Map<int, List<EpisodeItem>>>{};
for (final e in items) {
final bySeason =
map.putIfAbsent(e.showName, () => <int, List<EpisodeItem>>{});
final list = bySeason.putIfAbsent(e.seasonNumber, () => <EpisodeItem>[]);
list.add(e);
}
for (final bySeason in map.values) {
for (final eps in bySeason.values) {
eps.sort((a, b) => a.episodeNumber.compareTo(b.episodeNumber));
}
}
return SeriesGroupedData(map);
}
}
@@ -0,0 +1,235 @@
import 'dart:convert';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/status.dart';
import '../../movies/presentation/widgets/status_chip.dart';
import '../data/series_repository.dart';
import 'widgets/episode_status_strip.dart';
class SeriesListScreen extends ConsumerWidget {
const SeriesListScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final filter = ref.watch(episodeFilterProvider);
final groupedAsync = ref.watch(seriesGroupedProvider);
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
StatusChip(
selected: filter,
onChanged: (f) =>
ref.read(episodeFilterProvider.notifier).state = f,
),
const SizedBox(height: 12),
Expanded(
child: groupedAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Fehler: $e')),
data: (data) {
final shows = data.data.keys.toList()..sort();
if (shows.isEmpty) {
return const Center(
child: Text('Keine Episoden gefunden.'));
}
// Collect all season columns across all shows
final allSeasons = <int>{};
for (final m in data.data.values) {
allSeasons.addAll(m.keys);
}
final seasonCols = allSeasons.toList()..sort();
List<DataColumn> buildColumns() => [
const DataColumn(label: Text('Serie')),
for (final s in seasonCols) DataColumn(label: Text('Staffel $s')),
];
List<DataRow> buildRows() {
final rows = <DataRow>[];
for (final showName in shows) {
final bySeason = data.data[showName]!;
int? year;
String? resolution;
String? showJson;
String? showStatus;
bool? showCliffhanger;
String? posterPath;
final sortedSeasons = bySeason.keys.toList()..sort();
for (final s in sortedSeasons) {
final eps = bySeason[s]!;
for (final e in eps) {
year ??= e.firstAirYear;
resolution ??= e.resolution;
showJson ??= e.showJson;
showStatus ??= e.showStatus;
showCliffhanger ??= e.showCliffhanger;
posterPath ??= e.posterPath;
if (year != null && resolution != null && showStatus != null && showCliffhanger != null && posterPath != null) break;
}
if (year != null && resolution != null) break;
}
if ((year == null || showStatus == null || showCliffhanger == null) && showJson != null) {
try {
final m = jsonDecode(showJson!);
if (m is Map && m['first_air_date'] is String) {
final s = (m['first_air_date'] as String);
if (s.length >= 4) year = int.tryParse(s.substring(0, 4));
}
if (m is Map && showStatus == null && m['status'] is String) {
showStatus = m['status'] as String;
}
if (m is Map && showCliffhanger == null && m['cliffhanger'] != null) {
final v = m['cliffhanger'];
if (v is bool) showCliffhanger = v;
else if (v is num) showCliffhanger = v != 0;
else if (v is String) {
final s = v.toLowerCase();
showCliffhanger = (s == '1' || s == 'true' || s == 'yes');
}
}
} catch (_) {}
}
// Determine background for left cell based on episodes status
bool anyProgress = false;
bool anyInit = false;
for (final epsList in bySeason.values) {
for (final ep in epsList) {
if (ep.status == ItemStatus.Progress) anyProgress = true;
if (ep.status == ItemStatus.Init) anyInit = true;
}
}
Color? bg;
if (anyProgress) bg = Colors.blue;
else if (anyInit) bg = Colors.grey.shade200;
else bg = Colors.green;
final cells = <DataCell>[
DataCell(Container(
color: bg,
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
child: SizedBox(
width: 360,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (posterPath != null)
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: CachedNetworkImage(
imageUrl: 'https://image.tmdb.org/t/p/w154$posterPath',
width: 50,
height: 75,
fit: BoxFit.cover,
),
),
if (posterPath != null) const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
_seriesTitle(showName, year, showStatus, showCliffhanger, context),
const SizedBox(height: 4),
_resolutionInline(resolution, context),
],
),
),
],
),
),
)),
];
for (final s in seasonCols) {
final eps = bySeason[s];
if (eps == null || eps.isEmpty) {
cells.add(const DataCell(Text('-', textAlign: TextAlign.center)));
} else {
cells.add(DataCell(EpisodeStatusStrip(
episodes: eps,
barWidth: 7,
barHeight: 25,
spacing: 0,
)));
}
}
rows.add(DataRow(cells: cells));
}
return rows;
}
final table = DataTable(
columns: buildColumns(),
rows: buildRows(),
headingRowHeight: 40,
dataRowMinHeight: 88,
dataRowMaxHeight: 96,
columnSpacing: 16,
);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SingleChildScrollView(child: table),
);
},
),
),
],
),
),
);
}
Widget _resolutionInline(String? res, BuildContext context) {
if (res == null || res.isEmpty) return const SizedBox.shrink();
final m = RegExp(r"\d+").firstMatch(res);
final v = m != null ? int.tryParse(m.group(0)!) : null;
IconData? icon;
Color color = Theme.of(context).colorScheme.primary;
String label = res;
if (v != null) {
if (v >= 2000) {
icon = Icons.four_k;
color = Colors.deepOrange;
label = '${v}p';
} else if (v >= 1000) {
icon = Icons.hd;
color = Colors.blue;
label = '${v}p';
} else {
icon = Icons.sd_card;
color = Colors.grey;
label = '${v}p';
}
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) Icon(icon, size: 16, color: color),
if (icon != null) const SizedBox(width: 4),
Text(
label,
style: const TextStyle(fontSize: 12),
),
],
);
}
Widget _seriesTitle(String name, int? year, String? status, bool? cliff, BuildContext context) {
final base = Theme.of(context).textTheme.titleMedium;
final s = (status ?? '').toLowerCase();
final endedOrCanceled = s == 'ended' || s == 'canceled' || s == 'cancelled';
final fw = endedOrCanceled ? FontWeight.normal : FontWeight.w600;
final fs = (cliff == true) ? FontStyle.italic : FontStyle.normal;
final style = base?.copyWith(fontWeight: fw, fontStyle: fs);
final text = year != null ? '$name ($year)' : name;
return Text(text, overflow: TextOverflow.ellipsis, style: style);
}
}
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../../../../core/status.dart';
import '../../data/episode_model.dart';
class EpisodeStatusStrip extends StatelessWidget {
final List<EpisodeItem> episodes;
final double barWidth; // Breite eines Quadrats
final double barHeight; // Höhe eines Quadrats
final double spacing; // Abstand zwischen Quadraten
const EpisodeStatusStrip({
super.key,
required this.episodes,
this.barWidth = 7,
this.barHeight = 25,
this.spacing = 0,
});
Color _fill(ItemStatus s) {
switch (s) {
case ItemStatus.Init:
return Colors.grey.shade200; // wie Filme (Init)
case ItemStatus.Progress:
return Colors.blue; // wie Filme (Progress)
case ItemStatus.Done:
return Colors.green; // wie Filme (Done)
}
}
@override
Widget build(BuildContext context) {
if (episodes.isEmpty) return const SizedBox.shrink();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int i = 0; i < episodes.length; i++) ...[
Container(
width: barWidth,
height: barHeight,
decoration: BoxDecoration(
color: _fill(episodes[i].status),
border: Border(
left: BorderSide(color: Colors.black, width: i == 0 ? 1 : 0),
top: const BorderSide(color: Colors.black, width: 1),
right: const BorderSide(color: Colors.black, width: 1),
bottom: const BorderSide(color: Colors.black, width: 1),
),
),
),
if (i != episodes.length - 1 && spacing != 0) SizedBox(width: spacing),
],
],
),
);
}
}
+6
View File
@@ -0,0 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api/backend_api.dart';
import '../../core/api/tmdb_api.dart';
final backendApiProvider = Provider<BackendApi>((ref) => BackendApi());
final tmdbApiProvider = Provider<TmdbApi>((ref) => TmdbApi());