Skip to content

Commit b77759d

Browse files
feat: Implement notification system and fix UI regressions
1 parent 4d89df0 commit b77759d

13 files changed

Lines changed: 505 additions & 49 deletions

File tree

android/app/build.gradle

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ android {
2020
}
2121

2222
compileOptions {
23+
coreLibraryDesugaringEnabled true
2324
sourceCompatibility = JavaVersion.VERSION_1_8
2425
targetCompatibility = JavaVersion.VERSION_1_8
2526
}
@@ -30,7 +31,7 @@ android {
3031

3132
defaultConfig {
3233
applicationId = "com.ryan.anymex"
33-
minSdk = 23
34+
minSdkVersion = flutter.minSdkVersion
3435
targetSdk = flutter.targetSdkVersion
3536
versionCode = flutter.versionCode
3637
versionName = flutter.versionName
@@ -58,11 +59,15 @@ android {
5859

5960
buildTypes {
6061
release {
61-
signingConfig = signingConfigs.release
62+
signingConfig = signingConfigs.debug
6263
}
6364
}
6465
}
6566

6667
flutter {
6768
source = "../.."
6869
}
70+
71+
dependencies {
72+
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
73+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
2+
import 'package:anymex/controllers/services/anilist/anilist_auth.dart';
3+
import 'package:anymex/controllers/services/anilist/anilist_queries.dart';
4+
import 'package:anymex/models/Media/media.dart';
5+
import 'package:anymex/utils/notification_service.dart';
6+
import 'package:anymex/widgets/non_widgets/snackbar.dart';
7+
import 'package:get/get.dart';
8+
import 'package:http/http.dart' as http;
9+
import 'dart:convert';
10+
import 'package:anymex/utils/logger.dart';
11+
12+
class NotificationController extends GetxController {
13+
final anilistAuth = Get.find<AnilistAuth>();
14+
var airingSchedule = <Media>[].obs;
15+
var isLoading = false.obs;
16+
17+
@override
18+
void onInit() {
19+
super.onInit();
20+
fetchAiringSchedule();
21+
}
22+
23+
Future<void> fetchAiringSchedule() async {
24+
isLoading.value = true;
25+
try {
26+
final response = await http.post(
27+
Uri.parse('https://graphql.anilist.co'),
28+
headers: {'Content-Type': 'application/json'},
29+
body: json.encode({
30+
'query': airingScheduleQuery,
31+
'variables': {
32+
'page': 1,
33+
'perPage': 20,
34+
'airingAtGreater': (DateTime.now().subtract(const Duration(hours: 24)).millisecondsSinceEpoch / 1000).round(),
35+
'airingAtLesser': (DateTime.now().add(const Duration(days: 7)).millisecondsSinceEpoch / 1000).round(),
36+
}
37+
}),
38+
);
39+
40+
if (response.statusCode == 200) {
41+
final data = json.decode(response.body);
42+
final mediaList = data['data']['Page']['airingSchedules'];
43+
44+
// Filter based on user's anime list if logged in
45+
final userAnimeIds = anilistAuth.animeList.map((e) => e.id).toSet();
46+
47+
final List<Media> schedules = [];
48+
49+
for (var item in mediaList) {
50+
final media = Media.fromSmallJson(item['media'], false);
51+
media.nextAiringEpisode = item['episode'];
52+
media.description = "Aired at ${DateTime.fromMillisecondsSinceEpoch(item['airingAt'] * 1000).toString()}";
53+
// Store airing time for sort
54+
media.extraData = item['airingAt'].toString();
55+
56+
if (anilistAuth.isLoggedIn.value) {
57+
if (userAnimeIds.contains(media.id)) {
58+
schedules.add(media);
59+
_checkAndNotify(media, item['airingAt']);
60+
}
61+
} else {
62+
// If not logged in, just show popular ones
63+
schedules.add(media);
64+
}
65+
}
66+
67+
// Sort by airing time (most recent first)
68+
schedules.sort((a, b) => int.parse(b.extraData!).compareTo(int.parse(a.extraData!)));
69+
70+
airingSchedule.value = schedules;
71+
} else {
72+
Logger.e("Failed to fetch airing schedule: ${response.body}");
73+
}
74+
} catch (e) {
75+
Logger.e("Error fetching airing schedule: $e");
76+
} finally {
77+
isLoading.value = false;
78+
}
79+
}
80+
81+
void _checkAndNotify(Media media, int airingAt) {
82+
// Simple logic: if aired within last 1 hour, show notification
83+
// In a real background task, you'd store notified IDs to avoid duplicates
84+
final now = DateTime.now().millisecondsSinceEpoch / 1000;
85+
if (now - airingAt < 3600 && now >= airingAt) {
86+
NotificationService.showNotification(
87+
media.hashCode,
88+
"New Episode Released!",
89+
"${media.title} Episode ${media.nextAiringEpisode} is out now!",
90+
media.id,
91+
);
92+
}
93+
}
94+
}

lib/controllers/services/anilist/anilist_queries.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,3 +114,42 @@ averageScore
114114
}
115115
}
116116
''';
117+
118+
const airingScheduleQuery = '''
119+
query (\$page: Int, \$perPage: Int, \$airingAtGreater: Int, \$airingAtLesser: Int) {
120+
Page(page: \$page, perPage: \$perPage) {
121+
pageInfo {
122+
hasNextPage
123+
total
124+
}
125+
airingSchedules(
126+
airingAt_greater: \$airingAtGreater
127+
airingAt_lesser: \$airingAtLesser
128+
sort: TIME_DESC
129+
) {
130+
id
131+
episode
132+
airingAt
133+
media {
134+
id
135+
title {
136+
romaji
137+
english
138+
native
139+
}
140+
coverImage {
141+
extraLarge
142+
large
143+
medium
144+
color
145+
}
146+
type
147+
format
148+
averageScore
149+
favourites
150+
isAdult
151+
}
152+
}
153+
}
154+
}
155+
''';

lib/main.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ import 'package:anymex/widgets/custom_widgets/anymex_titlebar.dart';
4242
import 'package:anymex/widgets/helper/platform_builder.dart';
4343
import 'package:anymex/widgets/non_widgets/settings_sheet.dart';
4444
import 'package:anymex/widgets/non_widgets/snackbar.dart';
45+
import 'package:anymex/utils/notification_service.dart';
46+
import 'package:anymex/controllers/notification/notification_controller.dart';
4547
import 'package:app_links/app_links.dart';
4648
import 'package:cached_network_image/cached_network_image.dart';
4749
import 'package:firebase_analytics/firebase_analytics.dart';
@@ -109,6 +111,7 @@ void main(List<String> args) async {
109111
_initializeGetxController();
110112
initializeDateFormatting();
111113
MediaKit.ensureInitialized();
114+
await NotificationService.init();
112115
if (!Platform.isAndroid && !Platform.isIOS) {
113116
await windowManager.ensureInitialized();
114117
if (Platform.isWindows) {
@@ -197,6 +200,7 @@ void _initializeGetxController() async {
197200
Get.put(GreetingController());
198201
Get.put(CommentumService());
199202
Get.put(CommentPreloader());
203+
Get.put(NotificationController());
200204
Get.lazyPut(() => CacheController());
201205
// DownloadManagerBinding.initializeDownloadManager();
202206
}

lib/models/Media/media.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ class Media {
4444
DateTime? createdAt;
4545
bool? isAdult;
4646
String? sourceName;
47+
String? extraData;
4748

4849
// String get uniqueId => "$id-${serviceType.name}";
4950
String get uniqueId => id.split('*').first;
@@ -82,6 +83,7 @@ class Media {
8283
this.mediaContent,
8384
required this.serviceType,
8485
this.sourceName,
86+
this.extraData,
8587
DateTime? createdAt})
8688
: createdAt = DateTime.now();
8789

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
2+
import 'package:anymex/controllers/notification/notification_controller.dart';
3+
import 'package:anymex/models/Media/media.dart';
4+
import 'package:anymex/utils/function.dart';
5+
import 'package:anymex/utils/theme_extensions.dart';
6+
import 'package:anymex/widgets/common/glow.dart';
7+
import 'package:cached_network_image/cached_network_image.dart';
8+
import 'package:flutter/material.dart';
9+
import 'package:get/get.dart';
10+
import 'package:iconsax/iconsax.dart';
11+
12+
class NotificationPage extends StatelessWidget {
13+
const NotificationPage({super.key});
14+
15+
@override
16+
Widget build(BuildContext context) {
17+
final controller = Get.find<NotificationController>();
18+
final theme = Theme.of(context);
19+
20+
return Glow(
21+
child: Scaffold(
22+
body: Column(
23+
children: [
24+
const NestedHeader(title: 'Notifications'),
25+
Expanded(
26+
child: Obx(() {
27+
if (controller.isLoading.value) {
28+
return const Center(child: CircularProgressIndicator());
29+
}
30+
31+
if (controller.airingSchedule.isEmpty) {
32+
return Center(
33+
child: Column(
34+
mainAxisAlignment: MainAxisAlignment.center,
35+
children: [
36+
Icon(Iconsax.notification, size: 64, color: theme.colorScheme.primary.withOpacity(0.5)),
37+
const SizedBox(height: 16),
38+
Text(
39+
"No notifications yet",
40+
style: theme.textTheme.titleMedium,
41+
),
42+
],
43+
),
44+
);
45+
}
46+
47+
return ListView.builder(
48+
padding: const EdgeInsets.all(0),
49+
itemCount: controller.airingSchedule.length,
50+
itemBuilder: (context, index) {
51+
final media = controller.airingSchedule[index];
52+
return _NotificationTile(media: media);
53+
},
54+
);
55+
}),
56+
),
57+
],
58+
),
59+
),
60+
);
61+
}
62+
}
63+
64+
class NestedHeader extends StatelessWidget {
65+
final String title;
66+
const NestedHeader({super.key, required this.title});
67+
68+
@override
69+
Widget build(BuildContext context) {
70+
final theme = Theme.of(context);
71+
return Container(
72+
padding: const EdgeInsets.only(top: 50, left: 20, right: 20, bottom: 20),
73+
decoration: BoxDecoration(
74+
color: theme.colorScheme.surface.opaque(0.4),
75+
border: Border(
76+
bottom: BorderSide(
77+
color: theme.colorScheme.outline.opaque(0.2, iReallyMeanIt: true),
78+
width: 1,
79+
),
80+
),
81+
),
82+
child: Row(
83+
children: [
84+
IconButton(
85+
onPressed: () => Navigator.of(context).pop(),
86+
icon: Icon(
87+
Icons.arrow_back_ios_rounded,
88+
color: theme.colorScheme.onSurface,
89+
),
90+
style: IconButton.styleFrom(
91+
backgroundColor: theme.colorScheme.surfaceContainer
92+
.opaque(0.3, iReallyMeanIt: true),
93+
padding: const EdgeInsets.all(12),
94+
),
95+
),
96+
const SizedBox(width: 12),
97+
Expanded(
98+
child: Text(
99+
title,
100+
style: TextStyle(
101+
color: theme.colorScheme.onSurface,
102+
fontWeight: FontWeight.w600,
103+
fontSize: 22,
104+
),
105+
),
106+
),
107+
],
108+
),
109+
);
110+
}
111+
}
112+
113+
class _NotificationTile extends StatelessWidget {
114+
final Media media;
115+
const _NotificationTile({required this.media});
116+
117+
@override
118+
Widget build(BuildContext context) {
119+
final theme = Theme.of(context);
120+
return Padding(
121+
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
122+
child: Container(
123+
padding: const EdgeInsets.all(12),
124+
decoration: BoxDecoration(
125+
color: theme.colorScheme.surfaceContainer.withOpacity(0.3),
126+
borderRadius: BorderRadius.circular(16),
127+
border: Border.all(
128+
color: theme.colorScheme.outline.withOpacity(0.1),
129+
),
130+
),
131+
child: Row(
132+
children: [
133+
ClipRRect(
134+
borderRadius: BorderRadius.circular(12),
135+
child: CachedNetworkImage(
136+
imageUrl: media.cover ?? '',
137+
width: 60,
138+
height: 80,
139+
fit: BoxFit.cover,
140+
),
141+
),
142+
const SizedBox(width: 16),
143+
Expanded(
144+
child: Column(
145+
crossAxisAlignment: CrossAxisAlignment.start,
146+
children: [
147+
Text(
148+
media.title ?? 'Unknown',
149+
style: theme.textTheme.titleMedium?.copyWith(
150+
fontWeight: FontWeight.bold,
151+
),
152+
maxLines: 1,
153+
overflow: TextOverflow.ellipsis,
154+
),
155+
const SizedBox(height: 4),
156+
Text(
157+
"Episode ${media.nextAiringEpisode}",
158+
style: theme.textTheme.bodyMedium?.copyWith(
159+
color: theme.colorScheme.primary,
160+
fontWeight: FontWeight.w600,
161+
),
162+
),
163+
const SizedBox(height: 4),
164+
Text(
165+
media.description ?? '', // Contains relative time string set in controller
166+
style: theme.textTheme.bodySmall?.copyWith(
167+
color: theme.colorScheme.onSurfaceVariant,
168+
),
169+
maxLines: 2,
170+
overflow: TextOverflow.ellipsis,
171+
),
172+
],
173+
),
174+
),
175+
],
176+
),
177+
),
178+
);
179+
}
180+
}

0 commit comments

Comments
 (0)