Files
firka/firka/lib/ui/phone/screens/home/home_screen.dart

573 lines
17 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:firka/api/client/kreta_stream.dart';
import 'package:firka/api/exceptions/token.dart';
import 'package:firka/core/extensions.dart';
import 'package:firka/core/firka_bundle.dart';
import 'package:firka/services/active_account_helper.dart';
import 'package:firka/services/live_activity_service.dart';
import 'package:firka/core/settings.dart';
import 'package:firka/services/watch_sync_helper.dart';
import 'package:firka/app/app_state.dart';
import 'package:firka/ui/theme/style.dart';
import 'package:firka/ui/phone/pages/extras/reauth_toast.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter/services.dart';
import 'package:home_widget/home_widget.dart';
import 'package:majesticons_flutter/majesticons_flutter.dart';
import 'package:firka/data/widget.dart';
import 'package:firka/core/debug_helper.dart';
import 'package:firka/core/bloc/profile_picture_cubit.dart';
import 'package:firka/core/bloc/reauth_cubit.dart';
import 'package:firka/core/bloc/settings_cubit.dart';
import 'package:firka/core/state/firka_state.dart';
import 'package:firka/core/image_preloader.dart';
import 'package:firka/ui/shared/delayed_spinner.dart';
import 'package:firka/ui/shared/firka_icon.dart';
import '../../pages/extras/main_error.dart';
enum ActiveToastType { fetching, error, reauth, none }
bool _fetching = true;
bool _prefetched = false;
class HomeScreen extends StatefulWidget {
final Widget child;
const HomeScreen({super.key, required this.child});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends FirkaState<HomeScreen>
with WidgetsBindingObserver {
Widget? toast;
bool _disposed = false;
bool _preloadDone = false;
bool _didRunSecondaryICloudRecovery = false;
bool _prefetchInProgress = false;
bool _didRunLiveActivityLogin = false;
bool _hasCompletedFirstPrefetch = false;
ActiveToastType activeToast = ActiveToastType.none;
void _setupNotificationListener() {
final notificationChannel = MethodChannel('firka.app/notifications');
notificationChannel.setMethodCallHandler((call) async {
if (call.method == 'onNotificationTapped') {
logger.info('Notification tapped: ${call.arguments}');
final args = call.arguments as Map<Object?, Object?>?;
if (args == null) return;
final action = args['action'] as String?;
final route = args['route'] as String?;
if (action != null || route != null) {
logger.info('Navigating to timetable from notification');
appRouter?.go('/timetable');
}
}
});
}
void _setupWidgetDeepLinkListener() {
if (!Platform.isIOS) return;
final widgetChannel = MethodChannel('firka.app/widget_deep_link');
WidgetsBinding.instance.addPostFrameCallback((_) {
widgetChannel.invokeMethod<String>('getPendingDeepLink').then((link) {
if (link != null) _handleWidgetDeepLink(link);
});
});
widgetChannel.setMethodCallHandler((call) async {
if (call.method == 'onWidgetDeepLink') {
final link = call.arguments as String?;
if (link != null) _handleWidgetDeepLink(link);
}
});
}
void _handleWidgetDeepLink(String link) {
logger.info('Widget deep link received: $link');
switch (link) {
case 'home':
appRouter?.go('/home');
break;
case 'timetable':
appRouter?.go('/timetable');
break;
case 'grades':
appRouter?.go('/grades');
break;
default:
logger.warning('Unknown widget deep link: $link');
}
}
Future<void> _runSecondaryICloudRecoveryIfNeeded() async {
if (!Platform.isIOS || _didRunSecondaryICloudRecovery) return;
_didRunSecondaryICloudRecovery = true;
final activeToken = pickActiveToken(
tokens: initData.tokens,
settings: initData.settings,
preferredStudentIdNorm: initData.client.model.studentIdNorm,
);
final now = DateTime.now();
final shouldRunRecovery =
initData.client.needsReauth ||
activeToken == null ||
activeToken.expiryDate == null ||
activeToken.expiryDate!.isBefore(now.add(const Duration(seconds: 60)));
if (!shouldRunRecovery) return;
logger.info(
'[Home] Secondary iCloud recovery scheduled (5s delay, startup safety pass)',
);
await Future.delayed(const Duration(seconds: 5));
if (_disposed) return;
try {
final recovered = await WatchSyncHelper.checkAndRecoverFromiCloud(
isar: initData.isar,
tokens: initData.tokens,
client: initData.client,
);
if (!recovered) {
logger.info('[Home] Secondary iCloud recovery found no fresher token');
return;
}
final refreshedTokens = initDone ? initData.tokens : initData.tokens;
initData.tokens = refreshedTokens;
final selectedToken = pickActiveToken(
tokens: refreshedTokens,
settings: initData.settings,
preferredStudentIdNorm: initData.client.model.studentIdNorm,
);
if (selectedToken != null) {
initData.client.model = selectedToken;
}
initData.reauthCubit?.clear();
logger.info('[Home] Secondary iCloud recovery applied a fresher token');
} catch (e) {
logger.warning('[Home] Secondary iCloud recovery failed: $e');
}
}
void prefetch() async {
if (_prefetched) return;
if (_prefetchInProgress) return;
final lifecycle = WidgetsBinding.instance.lifecycleState;
if (lifecycle != null && lifecycle != AppLifecycleState.resumed) {
logger.info(
'[Home] prefetch: App is in background, deferring to foreground',
);
return;
}
_prefetchInProgress = true;
try {
_prefetched = true;
await _runSecondaryICloudRecoveryIfNeeded();
try {
await initData.client.refreshTokenProactively().timeout(
const Duration(seconds: 60),
onTimeout: () {
logger.warning('[Home] Token refresh/recovery timed out after 60s');
return false;
},
);
} catch (e) {
logger.warning('[Home] Token refresh/recovery failed: $e');
}
await fetchData();
if (Platform.isAndroid) {
await WidgetCacheHelper.updateWidgetCache(appStyle, initData.client);
await HomeWidget.updateWidget(
qualifiedAndroidName:
"app.firka.naplo.glance.TimetableWidgetReceiver",
);
}
if (Platform.isIOS) {
await WidgetCacheHelper.refreshIOSWidgets(
initData.client,
initData.settings,
);
if (!_didRunLiveActivityLogin) {
_didRunLiveActivityLogin = true;
final token = pickActiveToken(
tokens: initData.tokens,
settings: initData.settings,
);
final studentName = token?.studentId ?? "Student";
LiveActivityService.onUserLogin(
client: initData.client,
studentName: studentName,
settingsStore: initData.settings,
).catchError((e, st) {
logger.severe('LiveActivity registration failed: $e', e, st);
});
}
}
if (!_disposed &&
(LiveActivityService.isTokenExpired || initData.client.needsReauth)) {
activeToast = ActiveToastType.reauth;
setState(() {
toast = buildReauthToast(context, initData, () {
if (!_disposed) {
setState(() {
activeToast = ActiveToastType.none;
toast = null;
});
}
});
});
return;
}
} catch (e) {
if (e is TokenExpiredException || e is InvalidGrantException) {
activeToast = ActiveToastType.reauth;
if (_disposed) return;
setState(() {
toast = buildReauthToast(context, initData, () {
if (!_disposed) {
setState(() {
activeToast = ActiveToastType.none;
toast = null;
});
}
});
});
return;
}
activeToast = ActiveToastType.error;
var dismissDelay = 120;
if (kDebugMode) dismissDelay = 2;
Timer(Duration(seconds: dismissDelay), () {
if (_disposed) return;
setState(() {
activeToast = ActiveToastType.none;
toast = null;
});
});
if (_disposed) return;
setState(() {
toast = Positioned(
top: MediaQuery.of(context).size.height / 1.6,
left: 0.0,
right: 0.0,
bottom: 0,
child: Center(
child: Card(
color: appStyle.colors.errorCard,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(200)),
),
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 15, vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
initData.l10n.api_error,
style: appStyle.fonts.B_16SB.copyWith(
color: appStyle.colors.errorText,
),
),
SizedBox(width: 8),
GestureDetector(
child: FirkaIconWidget(
FirkaIconType.majesticons,
Majesticon.questionCircleSolid,
color: appStyle.colors.errorAccent,
size: 24,
),
onTap: () {
var stackTrace = "";
if (e is Error && e.stackTrace != null) {
stackTrace = e.stackTrace.toString();
}
showErrorBottomSheet(context, "$e\n$stackTrace");
},
),
],
),
),
),
),
);
});
} finally {
_prefetchInProgress = false;
_hasCompletedFirstPrefetch = true;
if (!_disposed) {
setState(() {
_fetching = false;
if (activeToast == ActiveToastType.fetching) toast = null;
});
}
}
}
Future<void> fetchData() async {
var lessonsFetched = 0;
var noticeBoardFetched = 0;
var infoBoardFetched = 0;
var studentFetched = 0;
var testsFetched = 0;
var gradesFetched = 0;
var homeworkFetched = 0;
final midnight = timeNow().getMidnight();
initData.client
.getTimeTableStream(
midnight,
midnight.add(Duration(hours: 23, minutes: 59)),
cacheOnly: false,
)
.forEach((lessons) {
lessonsFetched++;
});
initData.client.getNoticeBoardStream(cacheOnly: false).forEach((items) {
noticeBoardFetched++;
});
initData.client.getInfoBoardStream(cacheOnly: false).forEach((items) {
infoBoardFetched++;
});
initData.client.getStudentStream(cacheOnly: false).forEach((student) {
studentFetched++;
});
initData.client.getTestsStream(cacheOnly: false).forEach((tests) {
testsFetched++;
});
initData.client.getGradesStream(cacheOnly: false).forEach((grades) {
gradesFetched++;
});
initData.client.getHomeworkStream(cacheOnly: false).forEach((homework) {
homeworkFetched++;
});
final startTime = DateTime.now();
const maxWaitTime = Duration(seconds: 30);
while (lessonsFetched < 2 ||
noticeBoardFetched < 2 ||
infoBoardFetched < 2 ||
studentFetched < 2 ||
testsFetched < 2 ||
gradesFetched < 2 ||
homeworkFetched < 2) {
if (DateTime.now().difference(startTime) > maxWaitTime) {
logger.warning('[Home] fetchData timed out after 30s');
break;
}
await Future.delayed(Duration(milliseconds: 50));
}
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_setupNotificationListener();
_setupWidgetDeepLinkListener();
prefetch();
_preloadImages();
if (Platform.isIOS &&
initData.settings.group("settings").boolean("beta_warning")) {
Future.delayed(Duration(seconds: 3), () async {
await LiveActivityService.showConsentScreenIfNeeded();
});
}
}
Future<void> _preloadImages() async {
final imagePaths = initData.settings.appIcons.keys
.map((icon) => "assets/images/icons/$icon.webp")
.toList();
imagePaths.add("assets/images/background.webp");
try {
await ImagePreloader.preloadMultipleAssets(FirkaBundle(), imagePaths);
if (!mounted) return;
setState(() => _preloadDone = true);
} catch (e) {
logger.severe('Home: error preloading images: $e');
if (!mounted) return;
setState(() => _preloadDone = true);
}
}
@override
Widget build(BuildContext context) {
if (!_preloadDone) {
return Scaffold(
backgroundColor: appStyle.colors.background,
body: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [SizedBox(), DelayedSpinnerWidget(), SizedBox()],
),
SizedBox(),
],
),
);
}
if (_fetching) {
if (_disposed) return const SizedBox.shrink();
setState(() {
activeToast = ActiveToastType.fetching;
toast = Positioned(
top: MediaQuery.of(context).size.height / 1.6,
left: 0.0,
right: 0.0,
bottom: 0,
child: Center(
child: Card(
color: appStyle.colors.card,
shadowColor: Colors.transparent,
child: Padding(
padding: EdgeInsets.all(8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: appStyle.colors.accent,
),
),
SizedBox(width: 16),
Text(
initData.l10n.refreshing,
style: appStyle.fonts.B_16SB.copyWith(
color: appStyle.colors.textPrimary,
),
),
],
),
),
),
),
);
});
}
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
return BlocListener<SettingsCubit, SettingsState>(
listener: (context, state) {
if (mounted) setState(() {});
},
child: BlocListener<ProfilePictureCubit, ProfilePictureState>(
listener: (context, state) {
if (mounted) setState(() {});
},
child: BlocListener<ReauthCubit, ReauthState>(
listener: (context, state) {
if (!mounted || _disposed) return;
if (!state.needsReauth && activeToast == ActiveToastType.reauth) {
setState(() {
activeToast = ActiveToastType.none;
toast = null;
});
}
},
child: Scaffold(
backgroundColor: appStyle.colors.background,
body: SafeArea(
child: SizedBox(
height: MediaQuery.of(context).size.height,
child: Stack(
children: [widget.child, toast ?? SizedBox.shrink()],
),
),
),
),
),
),
);
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state);
if (state == AppLifecycleState.resumed && !_disposed) {
logger.info('[Home] App resumed to foreground, re-running prefetch');
_prefetched = false;
_didRunSecondaryICloudRecovery = false;
prefetch();
if (Platform.isIOS) {
_refreshLiveActivityOnResume();
}
}
}
void _refreshLiveActivityOnResume() async {
if (!_hasCompletedFirstPrefetch) return;
try {
final token = pickActiveToken(
tokens: initData.tokens,
settings: initData.settings,
);
final studentName = token?.studentId ?? "Student";
await LiveActivityService.checkAndUpdateTimetable(
client: initData.client,
studentName: studentName,
settingsStore: initData.settings,
);
} catch (e) {
logger.warning(
'[Home] LiveActivity timetable update on resume failed: $e',
);
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_disposed = true;
_fetching = false;
_prefetched = false;
activeToast = ActiveToastType.none;
super.dispose();
}
}