[verified] Publish ReelBox source under GPLv3
This commit is contained in:
commit
db2b505ca3
445 files changed
+105274
No files matched your search
+2363
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,865 @@
|
||||
part of 'catalog.dart';
|
||||
|
||||
/// Account-free discovery, using the observed routes rather than invented tags.
|
||||
class DiscoveryDirectoryPage extends StatelessWidget {
|
||||
const DiscoveryDirectoryPage({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: Text(tr(context, 'Explore more', 'کشف بیشتر'))),
|
||||
body: ListView(
|
||||
children: [
|
||||
for (final preset in <(String, String, String, int, String)>[
|
||||
('Best titles', 'بهترین عنوانها', 'mixed', 0, 'rating'),
|
||||
('Newest titles', 'جدیدترین عنوانها', 'mixed', 0, 'created'),
|
||||
('Popular titles', 'عنوانهای محبوب', 'mixed', 0, 'rating'),
|
||||
('Top 250 movies', '۲۵۰ فیلم برتر', 'movie', 0, 'imdb'),
|
||||
('Top 250 series', '۲۵۰ سریال برتر', 'serie', 0, 'imdb'),
|
||||
('Korean series', 'سریالهای کرهای', 'serie', 36, 'created'),
|
||||
(
|
||||
'Chinese / Japanese series',
|
||||
'سریالهای چینی / ژاپنی',
|
||||
'serie',
|
||||
37,
|
||||
'created',
|
||||
),
|
||||
('Turkish series', 'سریالهای ترکی', 'serie', 35, 'created'),
|
||||
('Indian series', 'سریالهای هندی', 'serie', 38, 'created'),
|
||||
('Indian movies', 'فیلمهای هندی', 'movie', 38, 'created'),
|
||||
(
|
||||
'Newest movies by year',
|
||||
'جدیدترین فیلمها بر اساس سال',
|
||||
'movie',
|
||||
0,
|
||||
'year',
|
||||
),
|
||||
(
|
||||
'Newest series by year',
|
||||
'جدیدترین سریالها بر اساس سال',
|
||||
'serie',
|
||||
0,
|
||||
'year',
|
||||
),
|
||||
if (!AppScope.of(context).usesIranCatalogue) ...[
|
||||
('Most-viewed movies', 'پربازدیدترین فیلمها', 'movie', 0, 'views'),
|
||||
(
|
||||
'Most-viewed series',
|
||||
'پربازدیدترین سریالها',
|
||||
'serie',
|
||||
0,
|
||||
'views',
|
||||
),
|
||||
],
|
||||
])
|
||||
ListTile(
|
||||
leading: const Icon(Icons.movie_filter_outlined),
|
||||
title: Text(tr(context, preset.$1, preset.$2)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _push(
|
||||
context,
|
||||
BrowsePage(
|
||||
initialKind: preset.$3,
|
||||
initialGenre: preset.$4,
|
||||
initialOrder: preset.$5,
|
||||
heading: tr(context, preset.$1, preset.$2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
for (final entry in <(String, String, Widget)>[
|
||||
(
|
||||
'Miniseries',
|
||||
'مینی سریال',
|
||||
const SearchPage(initialQuery: 'مینی سریال'),
|
||||
),
|
||||
('Sitcoms', 'سیتکام', const SearchPage(initialQuery: 'سیتکام')),
|
||||
('Actors', 'بازیگران', const ActorsPage()),
|
||||
('Countries', 'کشورها', const CountriesPage()),
|
||||
(
|
||||
'Themes & subjects',
|
||||
'موضوعها',
|
||||
const SubjectDirectoryPage(kind: DiscoveryDirectory.themes),
|
||||
),
|
||||
(
|
||||
'Franchises',
|
||||
'فرنچایزها',
|
||||
const SubjectDirectoryPage(kind: DiscoveryDirectory.franchises),
|
||||
),
|
||||
(
|
||||
'Most-shared titles',
|
||||
'عنوانهای بیشتر اشتراکگذاشتهشده',
|
||||
const SharedTitlesPage(),
|
||||
),
|
||||
(
|
||||
'New discoveries',
|
||||
'تازههای کشف',
|
||||
const SharedTitlesPage(newest: true),
|
||||
),
|
||||
('Editor picks', 'پیشنهاد سردبیر', const CuratedPage()),
|
||||
(
|
||||
'Best collections',
|
||||
'مجموعههای برتر',
|
||||
const CuratedPage(best: true),
|
||||
),
|
||||
(
|
||||
'Best by year',
|
||||
'بهترینهای هر سال',
|
||||
const RecommendationsPage(byYear: true),
|
||||
),
|
||||
])
|
||||
ListTile(
|
||||
title: Text(tr(context, entry.$1, entry.$2)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _push(context, entry.$3),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _DiscoveryHomeShelves extends StatefulWidget {
|
||||
const _DiscoveryHomeShelves();
|
||||
@override
|
||||
State<_DiscoveryHomeShelves> createState() => _DiscoveryHomeShelvesState();
|
||||
}
|
||||
|
||||
class _DiscoveryHomeShelvesState extends State<_DiscoveryHomeShelves> {
|
||||
Future<List<Map<String, dynamic>>>? _actors;
|
||||
Future<List<Tag>>? _countries;
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final api = AppScope.read(context).api;
|
||||
_actors ??= api.actors();
|
||||
_countries ??= api.countries();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _heading(
|
||||
context,
|
||||
tr(context, 'Popular actors', 'بازیگران محبوب'),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _push(context, const ActorsPage()),
|
||||
child: Text(tr(context, 'View all', 'نمایش همه')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AsyncResult<List<Map<String, dynamic>>>(
|
||||
future: _actors!,
|
||||
onRetry: () => setState(() {
|
||||
_actors = AppScope.read(context).api.actors();
|
||||
}),
|
||||
builder: (items) => items.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: _PeopleGrid(items: items.take(12).toList()),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 12, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _heading(
|
||||
context,
|
||||
tr(context, 'Discover by country', 'کشف بر اساس کشور'),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _push(context, const CountriesPage()),
|
||||
child: Text(tr(context, 'View all', 'نمایش همه')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AsyncResult<List<Tag>>(
|
||||
future: _countries!,
|
||||
onRetry: () => setState(() {
|
||||
_countries = AppScope.read(context).api.countries();
|
||||
}),
|
||||
builder: (items) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
for (final country
|
||||
in items
|
||||
.where(
|
||||
(tag) =>
|
||||
tag.id > 0 && ContentPolicy.allowsText(tag.title),
|
||||
)
|
||||
.take(16))
|
||||
ActionChip(
|
||||
label: Text(country.title),
|
||||
avatar: const Icon(Icons.public, size: 16),
|
||||
onPressed: () => _push(
|
||||
context,
|
||||
BrowsePage(
|
||||
initialKind: 'mixed',
|
||||
initialCountry: country.id,
|
||||
heading: country.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class CountriesPage extends StatefulWidget {
|
||||
const CountriesPage({super.key});
|
||||
@override
|
||||
State<CountriesPage> createState() => _CountriesPageState();
|
||||
}
|
||||
|
||||
class _CountriesPageState extends State<CountriesPage> {
|
||||
Future<List<Tag>>? _future;
|
||||
String _query = '';
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_future ??= AppScope.read(context).api.countries();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: Text(tr(context, 'Countries', 'کشورها'))),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
onChanged: (value) =>
|
||||
setState(() => _query = value.trim().toLowerCase()),
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
hintText: tr(context, 'Find a country', 'جستجوی کشور'),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: AsyncResult<List<Tag>>(
|
||||
future: _future!,
|
||||
onRetry: () => setState(() {
|
||||
_future = AppScope.read(context).api.countries();
|
||||
}),
|
||||
builder: (items) {
|
||||
final visible = items
|
||||
.where(
|
||||
(tag) =>
|
||||
tag.id > 0 &&
|
||||
ContentPolicy.allowsText(tag.title) &&
|
||||
tag.title.toLowerCase().contains(_query),
|
||||
)
|
||||
.toList();
|
||||
return visible.isEmpty
|
||||
? EmptyView(
|
||||
title: tr(
|
||||
context,
|
||||
'No countries found',
|
||||
'کشوری پیدا نشد',
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
children: [
|
||||
for (final country in visible)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.public),
|
||||
title: Text(country.title),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _push(
|
||||
context,
|
||||
BrowsePage(
|
||||
initialKind: 'mixed',
|
||||
initialCountry: country.id,
|
||||
heading: country.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class ActorsPage extends StatefulWidget {
|
||||
const ActorsPage({super.key});
|
||||
@override
|
||||
State<ActorsPage> createState() => _ActorsPageState();
|
||||
}
|
||||
|
||||
class _ActorsPageState extends State<ActorsPage> {
|
||||
String query = '';
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: Text(tr(context, 'Actors', 'بازیگران'))),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
hintText: tr(context, 'Search actors', 'جستجوی بازیگران'),
|
||||
),
|
||||
onSubmitted: (value) => setState(() => query = value.trim()),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _PagedObjects(
|
||||
key: ValueKey(query),
|
||||
load: (page) =>
|
||||
AppScope.read(context).api.actors(page: page, search: query),
|
||||
builder: (items) => _PeopleGrid(items: items),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _PeopleGrid extends StatelessWidget {
|
||||
const _PeopleGrid({required this.items});
|
||||
final List<Map<String, dynamic>> items;
|
||||
@override
|
||||
Widget build(BuildContext context) => Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
for (final person
|
||||
in items
|
||||
.map(actorMetadata)
|
||||
.where((row) => ContentPolicy.allowsText('${row['name']}')))
|
||||
SizedBox(
|
||||
width: 125,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => _push(context, ActorTitlesPage(person: person)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
children: [
|
||||
ClipOval(
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: _text(person, ['image']).isEmpty
|
||||
? const ColoredBox(
|
||||
color: Color(0xFF252733),
|
||||
child: Icon(Icons.person_outline, size: 44),
|
||||
)
|
||||
: MediaArtwork(url: _text(person, ['image'])),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_text(person, ['name']),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_text(person, ['role']).isNotEmpty)
|
||||
Text(
|
||||
ContentPolicy.sanitizeText(_text(person, ['role'])),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
class ActorTitlesPage extends StatefulWidget {
|
||||
const ActorTitlesPage({super.key, required this.person});
|
||||
final Map<String, dynamic> person;
|
||||
@override
|
||||
State<ActorTitlesPage> createState() => _ActorTitlesPageState();
|
||||
}
|
||||
|
||||
class _ActorTitlesPageState extends State<ActorTitlesPage> {
|
||||
ActorFilmography? _loader;
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final person = actorMetadata(widget.person);
|
||||
_loader ??= ActorFilmography(
|
||||
CatalogDiscovery(AppScope.read(context).api),
|
||||
id: _id(person, ['id']),
|
||||
name: _text(person, ['name']),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final person = actorMetadata(widget.person);
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(ContentPolicy.sanitizeText(_text(person, ['name']))),
|
||||
),
|
||||
body: _PagedTitles(
|
||||
load: _loader!.load,
|
||||
headerBuilder: () => Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipOval(
|
||||
child: SizedBox(
|
||||
width: 86,
|
||||
height: 86,
|
||||
child: _text(person, ['image']).isEmpty
|
||||
? const Icon(Icons.person_outline, size: 60)
|
||||
: MediaArtwork(url: _text(person, ['image'])),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final field in [
|
||||
('role', 'Role', 'نقش'),
|
||||
('born', 'Born', 'تولد'),
|
||||
('height', 'Height', 'قد'),
|
||||
])
|
||||
if (_text(person, [field.$1]).isNotEmpty)
|
||||
Text(
|
||||
'${tr(context, field.$2, field.$3)}: ${ContentPolicy.sanitizeText(_text(person, [field.$1]))}',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_text(person, ['bio']).isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: _Synopsis(
|
||||
text: ContentPolicy.sanitizeText(_text(person, ['bio'])),
|
||||
),
|
||||
),
|
||||
if (_loader!.usingNameFallback)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
tr(
|
||||
context,
|
||||
'Filmography unavailable. Showing title search by actor name.',
|
||||
'فیلموگرافی موجود نیست. نتایج جستجوی نام بازیگر نمایش داده میشود.',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SubjectDirectoryPage extends StatelessWidget {
|
||||
const SubjectDirectoryPage({super.key, required this.kind});
|
||||
final DiscoveryDirectory kind;
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
kind == DiscoveryDirectory.themes
|
||||
? tr(context, 'Themes & subjects', 'موضوعها')
|
||||
: tr(context, 'Franchises', 'فرنچایزها'),
|
||||
),
|
||||
),
|
||||
body: _PagedObjects(
|
||||
load: (page) =>
|
||||
CatalogDiscovery(AppScope.read(context).api)
|
||||
.directory(kind, page: page),
|
||||
builder: (items) => Column(
|
||||
children: [
|
||||
for (final item in items.where(
|
||||
(row) => ContentPolicy.allowsText(_text(row, ['title'])),
|
||||
))
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
kind == DiscoveryDirectory.themes
|
||||
? Icons.topic_outlined
|
||||
: Icons.collections_bookmark_outlined,
|
||||
),
|
||||
title: Text(_text(item, ['title'])),
|
||||
subtitle:
|
||||
kind == DiscoveryDirectory.franchises &&
|
||||
_text(item, ['img']).isNotEmpty
|
||||
? Text(ContentPolicy.sanitizeText(_text(item, ['img'])))
|
||||
: null,
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _push(
|
||||
context,
|
||||
SearchPage(initialQuery: _text(item, ['title'])),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class SharedTitlesPage extends StatelessWidget {
|
||||
const SharedTitlesPage({super.key, this.newest = false});
|
||||
final bool newest;
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
newest
|
||||
? tr(context, 'New discoveries', 'تازههای کشف')
|
||||
: tr(
|
||||
context,
|
||||
'Most-shared titles',
|
||||
'عنوانهای بیشتر اشتراکگذاشتهشده',
|
||||
),
|
||||
),
|
||||
),
|
||||
body: _PagedTitles(
|
||||
load: (page) =>
|
||||
CatalogDiscovery(AppScope.read(context).api)
|
||||
.sharedTitles(page: page, newest: newest),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class CuratedPage extends StatefulWidget {
|
||||
const CuratedPage({super.key, this.best = false});
|
||||
final bool best;
|
||||
@override
|
||||
State<CuratedPage> createState() => _CuratedPageState();
|
||||
}
|
||||
|
||||
class _CuratedPageState extends State<CuratedPage> {
|
||||
Future<List<Map<String, dynamic>>>? future;
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
future ??= AppScope.read(context).api.curatedLinks(best: widget.best);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.best
|
||||
? tr(context, 'Best collections', 'مجموعههای برتر')
|
||||
: tr(context, 'Editor picks', 'پیشنهاد سردبیر'),
|
||||
),
|
||||
),
|
||||
body: AsyncResult<List<Map<String, dynamic>>>(
|
||||
future: future!,
|
||||
onRetry: () => setState(() {
|
||||
future = AppScope.read(context).api.curatedLinks(best: widget.best);
|
||||
}),
|
||||
builder: (items) {
|
||||
final visible = items
|
||||
.where(
|
||||
(row) =>
|
||||
ContentPolicy.allowsText(_text(row, ['title'])) &&
|
||||
ContentPolicy.allowsUrl(_text(row, ['link'])),
|
||||
)
|
||||
.toList();
|
||||
return visible.isEmpty
|
||||
? EmptyView(
|
||||
title: tr(
|
||||
context,
|
||||
'No collections returned',
|
||||
'مجموعهای دریافت نشد',
|
||||
),
|
||||
)
|
||||
: ListView(
|
||||
children: [
|
||||
for (final item in visible)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.auto_awesome_outlined),
|
||||
title: Text(_text(item, ['title'])),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _push(
|
||||
context,
|
||||
_CuratedContents(
|
||||
title: _text(item, ['title']),
|
||||
link: _text(item, ['link']),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _CuratedContents extends StatelessWidget {
|
||||
const _CuratedContents({required this.title, required this.link});
|
||||
final String title, link;
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: _PagedTitles(
|
||||
load: (page) =>
|
||||
AppScope.read(context).api.curatedContent(link, page: page),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _PagedObjects extends StatefulWidget {
|
||||
const _PagedObjects({super.key, required this.load, required this.builder});
|
||||
final Future<List<Map<String, dynamic>>> Function(int) load;
|
||||
final Widget Function(List<Map<String, dynamic>>) builder;
|
||||
@override
|
||||
State<_PagedObjects> createState() => _PagedObjectsState();
|
||||
}
|
||||
|
||||
class _PagedObjectsState extends State<_PagedObjects> {
|
||||
final items = <Map<String, dynamic>>[];
|
||||
final _scroll = ScrollController();
|
||||
int page = 1, _emptyPages = 0;
|
||||
bool busy = false, end = false;
|
||||
Object? error;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scroll.addListener(_nearEnd);
|
||||
load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _nearEnd() {
|
||||
if (_emptyPages < 4 &&
|
||||
error == null &&
|
||||
_scroll.hasClients &&
|
||||
_scroll.position.extentAfter < 350) {
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
if (busy || end) return;
|
||||
setState(() {
|
||||
busy = true;
|
||||
error = null;
|
||||
});
|
||||
try {
|
||||
final result = await widget.load(page);
|
||||
if (!mounted) return;
|
||||
final known = items.map(jsonEncode).toSet();
|
||||
final fresh = result.where((row) => known.add(jsonEncode(row))).toList();
|
||||
setState(() {
|
||||
items.addAll(fresh);
|
||||
end = result is DiscoveryObjectPage
|
||||
? result.nextPage == null
|
||||
: result.isEmpty;
|
||||
page = result is DiscoveryObjectPage
|
||||
? result.nextPage ?? page
|
||||
: page + 1;
|
||||
_emptyPages = fresh.isEmpty ? _emptyPages + 1 : 0;
|
||||
});
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(
|
||||
() => error = const ApiException('Could not load. Please retry.'),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => busy = false);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _nearEnd();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
if (busy) return;
|
||||
setState(() {
|
||||
items.clear();
|
||||
page = 1;
|
||||
end = false;
|
||||
error = null;
|
||||
_emptyPages = 0;
|
||||
});
|
||||
await load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: ListView(
|
||||
controller: _scroll,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Text(
|
||||
'${items.length} ${tr(context, 'items loaded', 'مورد بارگذاری شده')}',
|
||||
),
|
||||
widget.builder(items),
|
||||
if (busy)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
if (error != null)
|
||||
EmptyView(
|
||||
title: tr(context, 'Could not load', 'بارگذاری ممکن نشد'),
|
||||
onRetry: load,
|
||||
),
|
||||
if (!busy && error == null && end && items.isEmpty)
|
||||
EmptyView(
|
||||
title: tr(
|
||||
context,
|
||||
'Nothing returned by the service',
|
||||
'سرویس موردی برنگرداند',
|
||||
),
|
||||
onRetry: _refresh,
|
||||
),
|
||||
if (!busy && !end && error == null)
|
||||
OutlinedButton(
|
||||
onPressed: () {
|
||||
_emptyPages = 0;
|
||||
load();
|
||||
},
|
||||
child: Text(tr(context, 'Load more', 'نمایش بیشتر')),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ContinueWatchingShelf extends StatelessWidget {
|
||||
const _ContinueWatchingShelf();
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final known = <String>{};
|
||||
final entries = AppScope.of(context).history
|
||||
.where(
|
||||
(entry) =>
|
||||
entry.positionSeconds >= 5 &&
|
||||
!entry.title.isChannel &&
|
||||
ContentPolicy.allowsTitleJson(entry.title.toJson()) &&
|
||||
known.add(entry.title.libraryKey),
|
||||
)
|
||||
.take(6)
|
||||
.toList();
|
||||
if (entries.isEmpty) return const SizedBox.shrink();
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: _heading(
|
||||
context,
|
||||
tr(context, 'Continue watching', 'ادامه تماشا'),
|
||||
),
|
||||
),
|
||||
for (final entry in entries)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.play_circle_outline),
|
||||
title: Text(entry.title.title),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (entry.episodeTitle.isNotEmpty) Text(entry.episodeTitle),
|
||||
LinearProgressIndicator(value: entry.progress),
|
||||
Text('${(entry.progress * 100).round()}%'),
|
||||
],
|
||||
),
|
||||
onTap: () => _continueCurrentHistory(context, entry),
|
||||
trailing: IconButton(
|
||||
tooltip: tr(context, 'Title details', 'جزئیات عنوان'),
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () => goTitle(context, entry.title),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _continueCurrentHistory(
|
||||
BuildContext context,
|
||||
WatchEntry entry,
|
||||
) async {
|
||||
try {
|
||||
final api = AppScope.read(context).api;
|
||||
final title = await api.poster(entry.title.id);
|
||||
if (!ContentPolicy.allowsTitleJson(title.toJson())) {
|
||||
throw const ApiException('Unavailable');
|
||||
}
|
||||
EpisodeRecord? episode;
|
||||
var playlist = <EpisodeRecord>[];
|
||||
if (entry.episodeId != null) {
|
||||
playlist = (await api.seasons(title.id))
|
||||
.expand((season) => season.episodes)
|
||||
.toList();
|
||||
episode = playlist
|
||||
.where((item) => item.id == entry.episodeId)
|
||||
.firstOrNull;
|
||||
if (episode == null) throw const ApiException('Episode unavailable');
|
||||
}
|
||||
final sources = (episode?.sources ?? title.sources)
|
||||
.where((source) => ContentPolicy.allowsUrl(source.url))
|
||||
.toList();
|
||||
final source = currentHistorySource(sources, entry.sourceUrl);
|
||||
if (source == null) throw const ApiException('Source unavailable');
|
||||
if (context.mounted) {
|
||||
await _play(
|
||||
context,
|
||||
title,
|
||||
source,
|
||||
episode: episode,
|
||||
playlist: playlist,
|
||||
alternatives: sources,
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
toast(
|
||||
context,
|
||||
tr(
|
||||
context,
|
||||
'Could not refresh the current playback source. Open title details and retry.',
|
||||
'بهروزرسانی منبع پخش ممکن نشد. جزئیات عنوان را باز کنید و دوباره تلاش کنید.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/models.dart';
|
||||
import '../data/content_policy.dart';
|
||||
import '../state/app_state.dart';
|
||||
import 'catalog.dart';
|
||||
|
||||
enum ToastKind { info, success, error }
|
||||
|
||||
void toast(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
ToastKind kind = ToastKind.info,
|
||||
}) {
|
||||
if (!context.mounted) return;
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final text = ContentPolicy.sanitizeText(message);
|
||||
final background = switch (kind) {
|
||||
ToastKind.error => colors.errorContainer,
|
||||
ToastKind.success => colors.tertiaryContainer,
|
||||
ToastKind.info => colors.inverseSurface,
|
||||
};
|
||||
final foreground = switch (kind) {
|
||||
ToastKind.error => colors.onErrorContainer,
|
||||
ToastKind.success => colors.onTertiaryContainer,
|
||||
ToastKind.info => colors.onInverseSurface,
|
||||
};
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: background,
|
||||
showCloseIcon: true,
|
||||
closeIconColor: foreground,
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(
|
||||
switch (kind) {
|
||||
ToastKind.error => Icons.error_outline,
|
||||
ToastKind.success => Icons.check_circle_outline,
|
||||
ToastKind.info => Icons.info_outline,
|
||||
},
|
||||
color: foreground,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text.isEmpty
|
||||
? tr(
|
||||
context,
|
||||
'This content is not available',
|
||||
'این محتوا در دسترس نیست',
|
||||
)
|
||||
: text,
|
||||
style: TextStyle(color: foreground),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void goTitle(BuildContext context, TitleRecord title) {
|
||||
if (!ContentPolicy.allowsTitleJson(title.toJson()) ||
|
||||
title.id <= 0 ||
|
||||
title.title.isEmpty) {
|
||||
toast(
|
||||
context,
|
||||
tr(context, 'This content is not available', 'این محتوا در دسترس نیست'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context)
|
||||
.push(MaterialPageRoute<void>(builder: (_) => TitlePage(title: title)));
|
||||
}
|
||||
|
||||
class EmptyView extends StatelessWidget {
|
||||
const EmptyView({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.message,
|
||||
this.icon = Icons.movie_outlined,
|
||||
this.onRetry,
|
||||
});
|
||||
final String title;
|
||||
final String? message;
|
||||
final IconData icon;
|
||||
final VoidCallback? onRetry;
|
||||
@override
|
||||
Widget build(BuildContext context) => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 36),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 44, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
message!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
FilledButton.tonalIcon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(tr(context, 'Try again', 'تلاش دوباره')),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class AsyncResult<T> extends StatelessWidget {
|
||||
const AsyncResult({
|
||||
super.key,
|
||||
required this.future,
|
||||
required this.builder,
|
||||
this.onRetry,
|
||||
});
|
||||
final Future<T> future;
|
||||
final Widget Function(T) builder;
|
||||
final VoidCallback? onRetry;
|
||||
@override
|
||||
Widget build(BuildContext context) => FutureBuilder<T>(
|
||||
future: future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(48),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return EmptyView(
|
||||
title: tr(
|
||||
context,
|
||||
'Could not load this page',
|
||||
'بارگذاری صفحه ممکن نشد',
|
||||
),
|
||||
message: snapshot.error.toString(),
|
||||
icon: Icons.wifi_off_rounded,
|
||||
onRetry: onRetry,
|
||||
);
|
||||
}
|
||||
if (!snapshot.hasData) {
|
||||
return EmptyView(
|
||||
title: tr(context, 'Nothing here yet', 'هنوز محتوایی وجود ندارد'),
|
||||
);
|
||||
}
|
||||
return builder(snapshot.data as T);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class MediaArtwork extends StatelessWidget {
|
||||
const MediaArtwork({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.fit = BoxFit.cover,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
final String url;
|
||||
final BoxFit fit;
|
||||
final double? width, height;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget fallback() => ColoredBox(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.local_movies_outlined,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
);
|
||||
final dataSaver =
|
||||
context
|
||||
.dependOnInheritedWidgetOfExactType<AppScope>()
|
||||
?.notifier
|
||||
?.prefs
|
||||
.dataSaver ??
|
||||
false;
|
||||
if (url.isEmpty || dataSaver || !ContentPolicy.allowsUrl(url)) {
|
||||
return SizedBox(width: width, height: height, child: fallback());
|
||||
}
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// A parent SizedBox often supplies thumbnail dimensions instead of the
|
||||
// optional widget width. Decode for that slot, not the 480px fallback.
|
||||
final logicalWidth = width ??
|
||||
(constraints.hasBoundedWidth ? constraints.maxWidth : null);
|
||||
final cacheWidth = logicalWidth == null || !logicalWidth.isFinite
|
||||
? 480
|
||||
: (logicalWidth * MediaQuery.devicePixelRatioOf(context))
|
||||
.ceil()
|
||||
.clamp(1, 1200);
|
||||
return Image.network(
|
||||
url,
|
||||
fit: fit,
|
||||
width: width,
|
||||
height: height,
|
||||
cacheWidth: cacheWidth,
|
||||
errorBuilder: (_, _, _) =>
|
||||
SizedBox(width: width, height: height, child: fallback()),
|
||||
loadingBuilder: (_, child, progress) =>
|
||||
progress == null ? child : fallback(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PosterCard extends StatelessWidget {
|
||||
const PosterCard({super.key, required this.title, this.width = 132});
|
||||
final TitleRecord title;
|
||||
final double width;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!ContentPolicy.allowsTitleJson(title.toJson()) ||
|
||||
title.id <= 0 ||
|
||||
title.title.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final state = AppScope.of(context);
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () => goTitle(context, title),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
MediaArtwork(url: title.image, width: width),
|
||||
if (title.imdb > 0)
|
||||
PositionedDirectional(
|
||||
top: 8,
|
||||
start: 8,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: .78),
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Text(
|
||||
'★ ${title.imdb.toStringAsFixed(1)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFFFFD777),
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
PositionedDirectional(
|
||||
bottom: 6,
|
||||
end: 6,
|
||||
child: IconButton.filledTonal(
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: 32,
|
||||
height: 32,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
iconSize: 18,
|
||||
tooltip: tr(context, 'Watchlist', 'تماشای بعدی'),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await state.toggleSaved(title);
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
toast(
|
||||
context,
|
||||
error.toString(),
|
||||
kind: ToastKind.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
icon: Icon(
|
||||
state.hasSaved(title.id, kind: title.type)
|
||||
? Icons.bookmark_rounded
|
||||
: Icons.bookmark_border_rounded,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
Text(
|
||||
title.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
[
|
||||
if (title.year > 0) '${title.year}',
|
||||
title.isSeries
|
||||
? tr(context, 'Series', 'سریال')
|
||||
: tr(context, 'Movie', 'فیلم'),
|
||||
].join(' · '),
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PosterRail extends StatelessWidget {
|
||||
const PosterRail({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.items,
|
||||
this.onMore,
|
||||
});
|
||||
final String title;
|
||||
final List<TitleRecord> items;
|
||||
final VoidCallback? onMore;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visible = items
|
||||
.where(
|
||||
(item) =>
|
||||
item.id > 0 &&
|
||||
item.title.isNotEmpty &&
|
||||
ContentPolicy.allowsTitleJson(item.toJson()),
|
||||
)
|
||||
.toList();
|
||||
if (visible.isEmpty || !ContentPolicy.allowsText(title)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.fromSTEB(20, 0, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
if (onMore != null)
|
||||
TextButton(
|
||||
onPressed: onMore,
|
||||
child: Text(tr(context, 'See all', 'نمایش همه')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 244,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: visible.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 13),
|
||||
itemBuilder: (_, index) => PosterCard(title: visible[index]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PosterGrid extends StatelessWidget {
|
||||
const PosterGrid({super.key, required this.items, this.sliver = false});
|
||||
final List<TitleRecord> items;
|
||||
|
||||
/// Compose directly into the owning viewport instead of measuring every row.
|
||||
final bool sliver;
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visible = items
|
||||
.where(
|
||||
(item) =>
|
||||
item.id > 0 &&
|
||||
item.title.isNotEmpty &&
|
||||
ContentPolicy.allowsTitleJson(item.toJson()),
|
||||
)
|
||||
.toList();
|
||||
if (visible.isEmpty) {
|
||||
return sliver
|
||||
? const SliverToBoxAdapter(child: SizedBox.shrink())
|
||||
: const SizedBox.shrink();
|
||||
}
|
||||
final state = AppScope.of(context);
|
||||
final Widget content;
|
||||
if (state.prefs.compactCatalog) {
|
||||
content = SliverList.separated(
|
||||
itemCount: visible.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final title = visible[index];
|
||||
return ListTile(
|
||||
key: ValueKey('compact-title-row:${title.libraryKey}'),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
minTileHeight: 96,
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: MediaArtwork(url: title.image, width: 56, height: 80),
|
||||
),
|
||||
title: Text(
|
||||
title.title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (title.year > 0) '${title.year}',
|
||||
title.isSeries
|
||||
? tr(context, 'Series', 'سریال')
|
||||
: tr(context, 'Movie', 'فیلم'),
|
||||
if (title.imdb > 0) 'IMDb ${title.imdb.toStringAsFixed(1)}',
|
||||
].join(' · '),
|
||||
),
|
||||
onTap: () => goTitle(context, title),
|
||||
trailing: IconButton(
|
||||
tooltip: tr(context, 'Watchlist', 'تماشای بعدی'),
|
||||
icon: Icon(
|
||||
state.hasSaved(title.id, kind: title.type)
|
||||
? Icons.bookmark
|
||||
: Icons.bookmark_outline,
|
||||
),
|
||||
onPressed: () async {
|
||||
try {
|
||||
await state.toggleSaved(title);
|
||||
} catch (error) {
|
||||
if (context.mounted) {
|
||||
toast(context, error.toString(), kind: ToastKind.error);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
content = SliverGrid.builder(
|
||||
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 190,
|
||||
childAspectRatio: .56,
|
||||
mainAxisSpacing: 20,
|
||||
crossAxisSpacing: 14,
|
||||
),
|
||||
itemCount: visible.length,
|
||||
itemBuilder: (_, index) => PosterCard(title: visible[index]),
|
||||
);
|
||||
}
|
||||
final padded = SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: content,
|
||||
);
|
||||
return sliver
|
||||
? padded
|
||||
: CustomScrollView(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
slivers: [padded],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../data/home_banners.dart';
|
||||
import '../state/app_state.dart';
|
||||
import 'common.dart';
|
||||
|
||||
/// Independent of home metadata loading/error UI so the official channel stays
|
||||
/// visible and usable even before configuration has arrived.
|
||||
class HomeBanners extends StatelessWidget {
|
||||
const HomeBanners({super.key, required this.future});
|
||||
final Future<List<HomeBanner>> future;
|
||||
|
||||
Future<void> _open(BuildContext context, Uri target) async {
|
||||
try {
|
||||
if (await launchUrl(target, mode: LaunchMode.externalApplication)) return;
|
||||
} catch (_) {
|
||||
// Do not expose platform exceptions, configuration values or URLs.
|
||||
}
|
||||
if (context.mounted) {
|
||||
toast(
|
||||
context,
|
||||
tr(context, 'Could not open this link', 'باز کردن پیوند ممکن نشد'),
|
||||
kind: ToastKind.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FutureBuilder<List<HomeBanner>>(
|
||||
future: future,
|
||||
builder: (context, snapshot) {
|
||||
final banners = [
|
||||
HomeBanner.telegram,
|
||||
if (!snapshot.hasError)
|
||||
...?snapshot.data?.where((item) => item.isAdvertisement).take(5),
|
||||
];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 0),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < banners.length; i++)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: i == banners.length - 1 ? 0 : 12,
|
||||
),
|
||||
child: _BannerCard(
|
||||
banner: banners[i],
|
||||
tapKey: Key(
|
||||
banners[i].isAdvertisement
|
||||
? 'home-banner-ad-$i'
|
||||
: 'home-banner-telegram',
|
||||
),
|
||||
onTap: () => _open(context, banners[i].target),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _BannerCard extends StatelessWidget {
|
||||
const _BannerCard({
|
||||
required this.banner,
|
||||
required this.tapKey,
|
||||
required this.onTap,
|
||||
});
|
||||
final HomeBanner banner;
|
||||
final Key tapKey;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = AppScope.of(context);
|
||||
final persian = state.locale == 'fa';
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final subtitle = banner.subtitle(persian);
|
||||
return Material(
|
||||
color: colors.primary.withValues(alpha: .08),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: colors.primary.withValues(alpha: .22)),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
key: tapKey,
|
||||
onTap: onTap,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (banner.image != null && !state.prefs.dataSaver)
|
||||
Image.network(
|
||||
banner.image.toString(),
|
||||
height: 112,
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 700,
|
||||
excludeFromSemantics: true,
|
||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
banner.isAdvertisement
|
||||
? Icons.ad_units_outlined
|
||||
: Icons.campaign_outlined,
|
||||
color: colors.primary,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (banner.isAdvertisement) ...[
|
||||
Text(
|
||||
tr(context, 'Advertisement', 'تبلیغات'),
|
||||
style: Theme.of(context).textTheme.labelSmall
|
||||
?.copyWith(
|
||||
color: colors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
banner.title(persian),
|
||||
style: Theme.of(context).textTheme.titleSmall
|
||||
?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
if (subtitle.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(color: colors.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
Icons.open_in_new_rounded,
|
||||
color: colors.primary,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+1518
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user