changed everything from filcnaplo to refilc finally

This commit is contained in:
Kima
2024-02-24 20:12:25 +01:00
parent 0d1c7b7143
commit 1171e3aaaf
655 changed files with 38728 additions and 44967 deletions

View File

@@ -0,0 +1,90 @@
import 'package:refilc/helpers/subject.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc/ui/date_widget.dart';
import 'package:refilc/utils/reverse_search.dart';
import 'package:refilc_kreta_api/models/absence.dart';
import 'package:refilc_kreta_api/models/subject.dart';
import 'package:refilc_mobile_ui/common/custom_snack_bar.dart';
import 'package:refilc_mobile_ui/common/widgets/absence/absence_viewable.dart';
import 'package:refilc_mobile_ui/common/hero_scrollview.dart';
import 'package:refilc_mobile_ui/pages/absences/absence_subject_view_container.dart';
import 'package:refilc_mobile_ui/pages/timetable/timetable_page.dart';
import 'package:refilc/ui/filter/sort.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:refilc/utils/format.dart';
import 'package:refilc_mobile_ui/common/widgets/absence/absence_view.i18n.dart';
import 'package:provider/provider.dart';
class AbsenceSubjectView extends StatelessWidget {
const AbsenceSubjectView(this.subject, {super.key, this.absences = const []});
final GradeSubject subject;
final List<Absence> absences;
static void show(GradeSubject subject, List<Absence> absences,
{required BuildContext context}) {
Navigator.of(context, rootNavigator: true)
.push<Absence>(CupertinoPageRoute(
builder: (context) =>
AbsenceSubjectView(subject, absences: absences)))
.then((value) {
if (value == null) return;
Future.delayed(const Duration(milliseconds: 250)).then((_) {
ReverseSearch.getLessonByAbsence(value, context).then((lesson) {
if (lesson != null) {
TimetablePage.jump(context, lesson: lesson);
} else {
ScaffoldMessenger.of(context).showSnackBar(CustomSnackBar(
content: Text("lesson_not_found".i18n,
style: const TextStyle(color: Colors.white)),
backgroundColor: AppColors.of(context).red,
context: context,
));
}
});
});
});
}
@override
Widget build(BuildContext context) {
final dateWidgets = absences
.map((a) => DateWidget(
widget: AbsenceViewable(a, padding: EdgeInsets.zero),
date: a.date,
))
.toList();
List<Widget> absenceTiles = sortDateWidgets(context,
dateWidgets: dateWidgets,
padding: const EdgeInsets.symmetric(vertical: 6.0),
hasShadow: true);
SettingsProvider settingsProvider = Provider.of<SettingsProvider>(context);
return Scaffold(
body: HeroScrollView(
title: subject.renamedTo ?? subject.name.capital(),
italic: subject.isRenamed && settingsProvider.renamedSubjectsItalics,
icon: SubjectIcon.resolveVariant(subject: subject, context: context),
child: AbsenceSubjectViewContainer(
child: CupertinoScrollbar(
child: ListView.builder(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.all(24.0),
shrinkWrap: true,
itemBuilder: (context, index) => Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: absenceTiles[index],
),
itemCount: absenceTiles.length,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
class AbsenceSubjectViewContainer extends InheritedWidget {
const AbsenceSubjectViewContainer({super.key, required super.child});
static AbsenceSubjectViewContainer? of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<AbsenceSubjectViewContainer>();
@override
bool updateShouldNotify(AbsenceSubjectViewContainer oldWidget) => false;
}

View File

@@ -0,0 +1,433 @@
// ignore_for_file: no_leading_underscores_for_local_identifiers
import 'dart:math';
import 'package:animations/animations.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:refilc/api/providers/update_provider.dart';
import 'package:refilc/ui/date_widget.dart';
import 'package:refilc_kreta_api/models/absence.dart';
import 'package:refilc_kreta_api/models/lesson.dart';
import 'package:refilc_kreta_api/models/subject.dart';
import 'package:refilc_kreta_api/models/week.dart';
import 'package:refilc_kreta_api/providers/absence_provider.dart';
import 'package:refilc_kreta_api/providers/note_provider.dart';
import 'package:refilc/api/providers/user_provider.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/providers/timetable_provider.dart';
import 'package:refilc_mobile_ui/common/action_button.dart';
import 'package:refilc_mobile_ui/common/empty.dart';
import 'package:refilc_mobile_ui/common/filter_bar.dart';
import 'package:refilc_mobile_ui/common/panel/panel.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_button.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_image.dart';
import 'package:refilc_mobile_ui/common/widgets/absence/absence_subject_tile.dart';
import 'package:refilc_mobile_ui/common/widgets/absence/absence_viewable.dart';
import 'package:refilc_mobile_ui/common/widgets/statistics_tile.dart';
import 'package:refilc_mobile_ui/common/widgets/miss_tile.dart';
import 'package:refilc_mobile_ui/pages/absences/absence_subject_view.dart';
import 'package:refilc/ui/filter/sort.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:provider/provider.dart';
import 'absences_page.i18n.dart';
enum AbsenceFilter { absences, delays, misses }
class SubjectAbsence {
GradeSubject subject;
List<Absence> absences;
double percentage;
SubjectAbsence(
{required this.subject, this.absences = const [], this.percentage = 0.0});
}
class AbsencesPage extends StatefulWidget {
const AbsencesPage({super.key});
@override
AbsencesPageState createState() => AbsencesPageState();
}
class AbsencesPageState extends State<AbsencesPage>
with TickerProviderStateMixin {
late UserProvider user;
late AbsenceProvider absenceProvider;
late TimetableProvider timetableProvider;
late NoteProvider noteProvider;
late UpdateProvider updateProvider;
late String firstName;
late TabController _tabController;
late List<SubjectAbsence> absences = [];
final Map<GradeSubject, Lesson> _lessonCount = {};
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
timetableProvider = Provider.of<TimetableProvider>(context, listen: false);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
for (final lesson in timetableProvider.getWeek(Week.current()) ?? []) {
if (!lesson.isEmpty &&
lesson.subject.id != '' &&
lesson.lessonYearIndex != null) {
_lessonCount.update(
lesson.subject,
(value) {
if (lesson.lessonYearIndex! > value.lessonYearIndex!) {
return lesson;
} else {
return value;
}
},
ifAbsent: () => lesson,
);
}
}
setState(() {});
});
}
void buildSubjectAbsences() {
Map<GradeSubject, SubjectAbsence> _absences = {};
for (final absence in absenceProvider.absences) {
if (absence.delay != 0) continue;
if (!_absences.containsKey(absence.subject)) {
_absences[absence.subject] =
SubjectAbsence(subject: absence.subject, absences: [absence]);
} else {
_absences[absence.subject]?.absences.add(absence);
}
}
_absences.forEach((subject, absence) {
final absentLessonsOfSubject = absenceProvider.absences
.where((e) => e.subject == subject && e.delay == 0)
.length;
final totalLessonsOfSubject = _lessonCount[subject]?.lessonYearIndex ?? 0;
double absentLessonsOfSubjectPercentage;
if (absentLessonsOfSubject <= totalLessonsOfSubject) {
absentLessonsOfSubjectPercentage =
absentLessonsOfSubject / totalLessonsOfSubject * 100;
} else {
absentLessonsOfSubjectPercentage = -1;
}
_absences[subject]?.percentage =
absentLessonsOfSubjectPercentage.clamp(-1, 100.0);
});
absences = _absences.values.toList();
absences.sort((a, b) => -a.percentage.compareTo(b.percentage));
}
@override
Widget build(BuildContext context) {
user = Provider.of<UserProvider>(context);
absenceProvider = Provider.of<AbsenceProvider>(context);
noteProvider = Provider.of<NoteProvider>(context);
updateProvider = Provider.of<UpdateProvider>(context);
timetableProvider = Provider.of<TimetableProvider>(context);
List<String> nameParts = user.displayName?.split(" ") ?? ["?"];
firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0];
buildSubjectAbsences();
return Scaffold(
body: Padding(
padding: const EdgeInsets.only(top: 12.0),
child: NestedScrollView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics()),
headerSliverBuilder: (context, _) => [
SliverAppBar(
pinned: true,
floating: false,
snap: false,
centerTitle: false,
surfaceTintColor: Theme.of(context).scaffoldBackgroundColor,
actions: [
// Profile Icon
Padding(
padding: const EdgeInsets.only(right: 24.0),
child: ProfileButton(
child: ProfileImage(
heroTag: "profile",
name: firstName,
backgroundColor: Theme.of(context)
.colorScheme
.primary, //ColorUtils.stringToColor(user.displayName ?? "?"),
badge: updateProvider.available,
role: user.role,
profilePictureString: user.picture,
),
),
),
],
automaticallyImplyLeading: false,
shadowColor: Theme.of(context).shadowColor,
title: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
"Absences".i18n,
style: TextStyle(
color: AppColors.of(context).text,
fontSize: 32.0,
fontWeight: FontWeight.bold),
),
),
bottom: FilterBar(
items: [
Tab(text: "Absences".i18n),
Tab(text: "Delays".i18n),
Tab(text: "Misses".i18n),
],
controller: _tabController,
disableFading: true,
),
),
],
body: TabBarView(
physics: const BouncingScrollPhysics(),
controller: _tabController,
children: List.generate(
3, (index) => filterViewBuilder(context, index))),
),
),
);
}
List<DateWidget> getFilterWidgets(AbsenceFilter activeData) {
List<DateWidget> items = [];
switch (activeData) {
case AbsenceFilter.absences:
for (var a in absences) {
items.add(DateWidget(
date: DateTime.fromMillisecondsSinceEpoch(0),
widget: AbsenceSubjectTile(
a.subject,
percentage: a.percentage,
excused: a.absences
.where((a) => a.state == Justification.excused)
.length,
unexcused: a.absences
.where((a) => a.state == Justification.unexcused)
.length,
pending: a.absences
.where((a) => a.state == Justification.pending)
.length,
onTap: () => AbsenceSubjectView.show(a.subject, a.absences,
context: context),
),
));
}
break;
case AbsenceFilter.delays:
for (var absence in absenceProvider.absences) {
if (absence.delay != 0) {
items.add(DateWidget(
date: absence.date,
widget: AbsenceViewable(absence, padding: EdgeInsets.zero),
));
}
}
break;
case AbsenceFilter.misses:
for (var note in noteProvider.notes) {
if (note.type?.name == "HaziFeladatHiany" ||
note.type?.name == "Felszereleshiany") {
items.add(DateWidget(
date: note.date,
widget: MissTile(note),
));
}
}
break;
}
return items;
}
Widget filterViewBuilder(context, int activeData) {
List<Widget> filterWidgets = [];
var absWidgets = getFilterWidgets(AbsenceFilter.values[activeData])
.map((e) => e.widget)
.cast<Widget>()
.toList();
if (activeData > 0) {
filterWidgets = sortDateWidgets(
context,
dateWidgets: getFilterWidgets(AbsenceFilter.values[activeData]),
padding: EdgeInsets.zero,
hasShadow: true,
);
} else if (absWidgets.isNotEmpty) {
filterWidgets = [
Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: Panel(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("Subjects".i18n),
Padding(
padding: const EdgeInsets.only(right: 4.0),
child: IconButton(
onPressed: () {
showDialog(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0)),
title: Text("attention".i18n),
content: Text("attention_body".i18n),
actions: [
ActionButton(
label: "Ok",
onTap: () => Navigator.of(context).pop(),
),
],
),
);
},
padding: EdgeInsets.zero,
splashRadius: 24.0,
visualDensity: VisualDensity.compact,
constraints: BoxConstraints.tight(const Size(42.0, 42.0)),
icon: const Icon(FeatherIcons.info),
),
),
],
),
child: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return FadeThroughTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
fillColor: Theme.of(context).colorScheme.background,
child: child,
);
},
child: Column(
children: absWidgets,
),
),
),
)
];
}
return Padding(
padding: const EdgeInsets.only(top: 12.0),
child: RefreshIndicator(
color: Theme.of(context).colorScheme.secondary,
onRefresh: () async {
await absenceProvider.fetch();
await noteProvider.fetch();
},
child: ListView.builder(
padding: EdgeInsets.zero,
physics: const BouncingScrollPhysics(),
itemCount: max(filterWidgets.length + (activeData <= 1 ? 1 : 0), 1),
itemBuilder: (context, index) {
if (filterWidgets.isNotEmpty) {
if ((index == 0 && activeData == 1) ||
(index == 0 && activeData == 0)) {
int value1 = 0;
int value2 = 0;
String title1 = "";
String title2 = "";
String suffix = "";
if (activeData == AbsenceFilter.absences.index) {
value1 = absenceProvider.absences
.where((e) =>
e.delay == 0 && e.state == Justification.excused)
.length;
value2 = absenceProvider.absences
.where((e) =>
e.delay == 0 && e.state == Justification.unexcused)
.length;
title1 = "stat_1".i18n;
title2 = "stat_2".i18n;
suffix = " ${"hr".i18n}";
} else if (activeData == AbsenceFilter.delays.index) {
value1 = absenceProvider.absences
.where((e) =>
e.delay != 0 && e.state == Justification.excused)
.map((e) => e.delay)
.fold(0, (a, b) => a + b);
value2 = absenceProvider.absences
.where((e) =>
e.delay != 0 && e.state == Justification.unexcused)
.map((e) => e.delay)
.fold(0, (a, b) => a + b);
title1 = "stat_3".i18n;
title2 = "stat_4".i18n;
suffix = " ${"min".i18n}";
}
return Padding(
padding: const EdgeInsets.only(
bottom: 24.0, left: 24.0, right: 24.0),
child: Row(children: [
Expanded(
child: StatisticsTile(
title: AutoSizeText(
title1,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
valueSuffix: suffix,
value: value1.toDouble(),
decimal: false,
color: AppColors.of(context).green,
),
),
const SizedBox(width: 24.0),
Expanded(
child: StatisticsTile(
title: AutoSizeText(
title2,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
valueSuffix: suffix,
value: value2.toDouble(),
decimal: false,
color: AppColors.of(context).red,
),
),
]),
);
}
return Padding(
padding:
const EdgeInsets.symmetric(horizontal: 24.0, vertical: 6.0),
child: filterWidgets[index - (activeData <= 1 ? 1 : 0)],
);
} else {
return Empty(subtitle: "empty".i18n);
}
},
),
),
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:i18n_extension/i18n_extension.dart';
extension ScreensLocalization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"Absences": "Absences",
"Delays": "Delays",
"Misses": "Misses",
"empty": "You have no absences.",
"stat_1": "Excused Absences",
"stat_2": "Unexcused Absences",
"stat_3": "Excused Delay",
"stat_4": "Unexcused Delay",
"min": "min",
"hr": "hrs",
"Subjects": "Subjects",
"attention": "Attention!",
"attention_body": "Percentage calculations are only an approximation so they may not be accurate.",
"lesson_not_found": "Cannot find lesson",
},
"hu_hu": {
"Absences": "Hiányzások",
"Delays": "Késések",
"Misses": "Hiányok",
"empty": "Nincsenek hiányaid.",
"stat_1": "Igazolt hiányzások",
"stat_2": "Igazolatlan hiányzások",
"stat_3": "Igazolt Késés",
"stat_4": "Igazolatlan Késés",
"min": "perc",
"hr": "óra",
"Subjects": "Tantárgyak",
"attention": "Figyelem!",
"attention_body": "A százalékos számítások csak közelítések, ezért előfordulhat, hogy nem pontosak.",
"lesson_not_found": "Nem található óra",
},
"de_de": {
"Absences": "Fehlen",
"Delays": "Verspätung",
"Misses": "Fehlt",
"empty": "Sie haben keine Fehlen.",
"stat_1": "Entschuldigte Fehlen",
"stat_2": "Unentschuldigte Fehlen",
"stat_3": "Entschuldigte Verspätung",
"stat_4": "Unentschuldigte Verspätung",
"min": "min",
"hr": "hrs",
"Subjects": "Fächer",
"attention": "Achtung!",
"attention_body": "Prozentberechnungen sind nur eine Annäherung und können daher ungenau sein.",
"lesson_not_found": "Lektion kann nicht gefunden werden",
},
};
String get i18n => localize(this, _t);
String fill(List<Object> params) => localizeFill(this, params);
String plural(int value) => localizePlural(value, this, _t);
String version(Object modifier) => localizeVersion(modifier, this, _t);
}

View File

@@ -0,0 +1,106 @@
import 'package:refilc/theme/colors/colors.dart';
// import 'package:refilc_plus/models/premium_scopes.dart';
// import 'package:refilc_plus/providers/premium_provider.dart';
// import 'package:refilc_plus/ui/mobile/premium/upsell.dart';
import 'package:flutter/material.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:refilc_mobile_ui/pages/grades/grades_page.i18n.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
// import 'package:provider/provider.dart';
final Map<int, String> avgDropItems = {
0: "annual_average",
90: "3_months_average",
30: "30_days_average",
14: "14_days_average",
7: "7_days_average",
};
class AverageSelector extends StatefulWidget {
const AverageSelector({super.key, this.onChanged, required this.value});
final Function(int?)? onChanged;
final int value;
@override
AverageSelectorState createState() => AverageSelectorState();
}
class AverageSelectorState extends State<AverageSelector> {
@override
Widget build(BuildContext context) {
List<DropdownMenuItem<int>> dropdownItems = avgDropItems.keys.map((item) {
return DropdownMenuItem<int>(
value: item,
child: Text(
avgDropItems[item]!.i18n,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColors.of(context).text,
),
overflow: TextOverflow.ellipsis,
),
);
}).toList();
return DropdownButton2<int>(
items: dropdownItems,
onChanged: (int? value) {
// if (Provider.of<PremiumProvider>(context, listen: false)
// .hasScope(PremiumScopes.gradeStats)) {
if (widget.onChanged != null) {
setState(() {
widget.onChanged!(value);
});
}
// } else {
// PremiumLockedFeatureUpsell.show(
// context: context, feature: PremiumFeature.gradestats);
// }
},
value: widget.value,
iconSize: 14,
iconEnabledColor: AppColors.of(context).text,
iconDisabledColor: AppColors.of(context).text,
underline: const SizedBox(),
itemHeight: 40,
itemPadding: const EdgeInsets.only(left: 14, right: 14),
dropdownWidth: 200,
dropdownPadding: null,
buttonDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
dropdownDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
),
dropdownElevation: 8,
scrollbarRadius: const Radius.circular(40),
scrollbarThickness: 6,
scrollbarAlwaysShow: true,
offset: const Offset(-10, -10),
buttonSplashColor: Colors.transparent,
customButton: SizedBox(
height: 30,
child: Row(
children: [
Text(
avgDropItems[widget.value]!.i18n,
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.of(context).text.withOpacity(0.65)),
),
const SizedBox(
width: 4,
),
Icon(
FeatherIcons.chevronDown,
size: 16,
color: AppColors.of(context).text,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,184 @@
import 'dart:math';
import 'package:refilc_kreta_api/models/category.dart';
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:refilc_kreta_api/models/subject.dart';
import 'package:refilc_kreta_api/models/teacher.dart';
import 'package:refilc_mobile_ui/common/custom_snack_bar.dart';
import 'package:refilc_mobile_ui/common/material_action_button.dart';
import 'package:refilc/ui/widgets/grade/grade_tile.dart';
import 'package:refilc_mobile_ui/pages/grades/calculator/grade_calculator_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'grade_calculator.i18n.dart';
class GradeCalculator extends StatefulWidget {
const GradeCalculator(this.subject, {super.key});
final GradeSubject? subject;
@override
GradeCalculatorState createState() => GradeCalculatorState();
}
class GradeCalculatorState extends State<GradeCalculator> {
late GradeCalculatorProvider calculatorProvider;
final _weightController = TextEditingController(text: "100");
double newValue = 5.0;
double newWeight = 100.0;
@override
Widget build(BuildContext context) {
calculatorProvider = Provider.of<GradeCalculatorProvider>(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(6.0),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"Grade Calculator".i18n,
style:
const TextStyle(fontSize: 20.0, fontWeight: FontWeight.w600),
),
),
// Grade value
Row(children: [
Expanded(
child: Slider(
thumbColor: Theme.of(context).colorScheme.secondary,
activeColor: Theme.of(context).colorScheme.secondary,
value: newValue,
min: 1.0,
max: 5.0,
divisions: 4,
label: "${newValue.toInt()}",
onChanged: (value) => setState(() => newValue = value),
),
),
Container(
width: 80.0,
padding: const EdgeInsets.only(right: 12.0),
child: Center(
child: GradeValueWidget(
GradeValue(newValue.toInt(), "", "", 0))),
),
]),
// Grade weight
Row(children: [
Expanded(
child: Slider(
thumbColor: Theme.of(context).colorScheme.secondary,
activeColor: Theme.of(context).colorScheme.secondary,
value: newWeight.clamp(50, 400),
min: 50.0,
max: 400.0,
divisions: 7,
label: "${newWeight.toInt()}%",
onChanged: (value) => setState(() {
newWeight = value;
_weightController.text = newWeight.toInt().toString();
}),
),
),
Container(
width: 80.0,
padding: const EdgeInsets.only(right: 12.0),
child: Center(
child: TextField(
controller: _weightController,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 22.0),
autocorrect: false,
textAlign: TextAlign.right,
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'[0-9]')),
LengthLimitingTextInputFormatter(3),
],
decoration: const InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
hintText: "100",
suffixText: "%",
suffixStyle: TextStyle(fontSize: 18.0),
),
onChanged: (value) {
setState(() {
newWeight = double.tryParse(value) ?? 100.0;
});
},
),
),
),
]),
Container(
width: 120.0,
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: MaterialActionButton(
child: Text("Add Grade".i18n),
onPressed: () {
if (calculatorProvider.ghosts.length >= 50) {
ScaffoldMessenger.of(context).showSnackBar(CustomSnackBar(
content: Text("limit_reached".i18n), context: context));
return;
}
DateTime date;
if (calculatorProvider.ghosts.isNotEmpty) {
List<Grade> grades = calculatorProvider.ghosts;
grades.sort((a, b) => -a.writeDate.compareTo(b.writeDate));
date = grades.first.date.add(const Duration(days: 7));
} else {
List<Grade> grades = calculatorProvider.grades
.where((e) =>
e.type == GradeType.midYear &&
(e.subject == widget.subject ||
widget.subject == null))
.toList();
grades.sort((a, b) => -a.writeDate.compareTo(b.writeDate));
date = grades.first.date;
}
calculatorProvider.addGhost(Grade(
id: randomId(),
date: date,
writeDate: date,
description: "Ghost Grade".i18n,
value:
GradeValue(newValue.toInt(), "", "", newWeight.toInt()),
teacher: Teacher.fromString("Ghost"),
type: GradeType.ghost,
form: "",
subject: widget.subject ??
GradeSubject(
id: randomId(),
category: Category(id: randomId()),
name: 'All',
),
mode: Category.fromJson({}),
seenDate: DateTime(0),
groupId: "",
));
},
),
),
],
),
);
}
String randomId() {
var rng = Random();
return rng.nextInt(1000000000).toString();
}
}

View File

@@ -0,0 +1,33 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"Grades": "Grades",
"Ghost Grade": "Ghost Grade",
"Grade Calculator": "Average calculator",
"Add Grade": "Add Grade",
"limit_reached": "You cannot add more Ghost Grades.",
},
"hu_hu": {
"Grades": "Jegyek",
"Ghost Grade": "Szellem jegy",
"Grade Calculator": "Átlag számoló",
"Add Grade": "Hozzáadás",
"limit_reached": "Nem adhatsz hozzá több jegyet.",
},
"de_de": {
"Grades": "Noten",
"Ghost Grade": "Geist Noten",
"Grade Calculator": "Mittelwert-Rechner",
"Add Grade": "Hinzufügen",
"limit_reached": "Sie können keine weiteren Noten hinzufügen.",
},
};
String get i18n => localize(this, _t);
String fill(List<Object> params) => localizeFill(this, params);
String plural(int value) => localizePlural(value, this, _t);
String version(Object modifier) => localizeVersion(modifier, this, _t);
}

View File

@@ -0,0 +1,47 @@
// import 'package:refilc/api/providers/database_provider.dart';
// import 'package:refilc/api/providers/user_provider.dart';
// import 'package:refilc/models/settings.dart';
// import 'package:refilc_kreta_api/client/client.dart';
import 'package:refilc_kreta_api/providers/grade_provider.dart';
import 'package:refilc_kreta_api/models/grade.dart';
class GradeCalculatorProvider extends GradeProvider {
GradeCalculatorProvider({
super.initialGrades,
required super.settings,
required super.user,
required super.database,
required super.kreta,
});
List<Grade> _grades = [];
List<Grade> _ghosts = [];
@override
List<Grade> get grades => _grades + _ghosts;
List<Grade> get ghosts => _ghosts;
void addGhost(Grade grade) {
_ghosts.add(grade);
notifyListeners();
}
void addGrade(Grade grade) {
_grades.add(grade);
notifyListeners();
}
void removeGrade(Grade ghost) {
_ghosts.removeWhere((e) => ghost.id == e.id);
notifyListeners();
}
void addAllGrades(List<Grade> grades) {
_grades.addAll(grades);
notifyListeners();
}
void clear() {
_grades = [];
_ghosts = [];
}
}

View File

@@ -0,0 +1,40 @@
import 'package:refilc_kreta_api/models/subject.dart';
import 'package:refilc_mobile_ui/common/panel/panel.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'grades_page.i18n.dart';
class FailWarning extends StatelessWidget {
const FailWarning({super.key, required this.subjectAvgs});
final Map<GradeSubject, double> subjectAvgs;
@override
Widget build(BuildContext context) {
final failingSubjectCount =
subjectAvgs.values.where((avg) => avg < 2.0).length;
if (failingSubjectCount == 0) {
return const SizedBox();
}
return Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: Panel(
title: Text("fail_warning".i18n),
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(
FeatherIcons.alertTriangle,
color: Colors.orange.withOpacity(.5),
size: 20.0,
),
const SizedBox(width: 12.0),
Text("fail_warning_description".i18n.fill([failingSubjectCount])),
],
),
),
);
}
}

View File

@@ -0,0 +1,367 @@
import 'dart:math';
import 'package:animations/animations.dart';
import 'package:refilc/api/providers/database_provider.dart';
import 'package:refilc/api/providers/user_provider.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/utils/format.dart';
// import 'package:refilc_kreta_api/client/api.dart';
// import 'package:refilc_kreta_api/client/client.dart';
import 'package:refilc_kreta_api/providers/grade_provider.dart';
import 'package:refilc/helpers/average_helper.dart';
import 'package:refilc/helpers/subject.dart';
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:refilc_kreta_api/models/subject.dart';
import 'package:refilc_mobile_ui/common/average_display.dart';
import 'package:refilc_mobile_ui/common/bottom_sheet_menu/rounded_bottom_sheet.dart';
import 'package:refilc_mobile_ui/common/panel/panel.dart';
import 'package:refilc_mobile_ui/common/trend_display.dart';
import 'package:refilc_mobile_ui/common/widgets/cretification/certification_tile.dart';
import 'package:refilc/ui/widgets/grade/grade_tile.dart';
import 'package:refilc_mobile_ui/common/widgets/grade/grade_viewable.dart';
import 'package:refilc_mobile_ui/common/hero_scrollview.dart';
import 'package:refilc_mobile_ui/pages/grades/calculator/grade_calculator.dart';
import 'package:refilc_mobile_ui/pages/grades/calculator/grade_calculator_provider.dart';
import 'package:refilc_mobile_ui/pages/grades/grades_count.dart';
import 'package:refilc_mobile_ui/pages/grades/graph.dart';
import 'package:refilc_mobile_ui/pages/grades/subject_grades_container.dart';
import 'package:refilc_plus/ui/mobile/goal_planner/goal_planner_screen.dart';
// import 'package:refilc_plus/models/premium_scopes.dart';
// import 'package:refilc_plus/providers/premium_provider.dart';
import 'package:refilc_plus/ui/mobile/goal_planner/goal_state_screen.dart';
// import 'package:refilc_plus/ui/mobile/premium/upsell.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_expandable_fab/flutter_expandable_fab.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:provider/provider.dart';
import 'grades_page.i18n.dart';
// import 'package:refilc_plus/ui/mobile/goal_planner/new_goal.dart';
class GradeSubjectView extends StatefulWidget {
const GradeSubjectView(this.subject, {super.key, this.groupAverage = 0.0});
final GradeSubject subject;
final double groupAverage;
void push(BuildContext context, {bool root = false}) {
Navigator.of(context, rootNavigator: root)
.push(CupertinoPageRoute(builder: (context) => this));
}
@override
State<GradeSubjectView> createState() => _GradeSubjectViewState();
}
class _GradeSubjectViewState extends State<GradeSubjectView> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
// Controllers
PersistentBottomSheetController? _sheetController;
final ScrollController _scrollController = ScrollController();
List<Widget> gradeTiles = [];
// Providers
late GradeProvider gradeProvider;
late GradeCalculatorProvider calculatorProvider;
late SettingsProvider settingsProvider;
late DatabaseProvider dbProvider;
late UserProvider user;
late double average;
late Widget gradeGraph;
bool gradeCalcMode = false;
String plan = '';
List<Grade> getSubjectGrades(GradeSubject subject) => !gradeCalcMode
? gradeProvider.grades.where((e) => e.subject == subject).toList()
: calculatorProvider.grades.where((e) => e.subject == subject).toList();
bool showGraph(List<Grade> subjectGrades) {
if (gradeCalcMode) return true;
final gradeDates = subjectGrades.map((e) => e.date.millisecondsSinceEpoch);
final maxGradeDate = gradeDates.fold(0, max);
final minGradeDate = gradeDates.fold(0, min);
if (maxGradeDate - minGradeDate < const Duration(days: 5).inMilliseconds) {
return false; // naplo/#78
}
return subjectGrades.where((e) => e.type == GradeType.midYear).length > 1;
}
void buildTiles(List<Grade> subjectGrades) {
List<Widget> tiles = [];
if (showGraph(subjectGrades)) {
tiles.add(gradeGraph);
} else {
tiles.add(Container(height: 24.0));
}
tiles.add(Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: Panel(
child: GradesCount(grades: getSubjectGrades(widget.subject).toList()),
),
));
// ignore: no_leading_underscores_for_local_identifiers
List<Widget> _gradeTiles = [];
if (!gradeCalcMode) {
subjectGrades.sort((a, b) => -a.date.compareTo(b.date));
for (var grade in subjectGrades) {
if (grade.type == GradeType.midYear) {
_gradeTiles.add(GradeViewable(grade));
} else {
_gradeTiles.add(CertificationTile(grade, padding: EdgeInsets.zero));
}
}
} else if (subjectGrades.isNotEmpty) {
subjectGrades.sort((a, b) => -a.date.compareTo(b.date));
for (var grade in subjectGrades) {
_gradeTiles.add(GradeTile(grade));
}
}
tiles.add(
PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.vertical,
fillColor: Colors.transparent,
child: child,
);
},
child: _gradeTiles.isNotEmpty
? Panel(
key: ValueKey(gradeCalcMode),
title: Text(
gradeCalcMode ? "Ghost Grades".i18n : "Grades".i18n,
),
child: Column(
children: _gradeTiles,
))
: const SizedBox(),
),
);
tiles.add(Padding(
padding: EdgeInsets.only(bottom: !gradeCalcMode ? 24.0 : 250.0)));
gradeTiles = List.castFrom(tiles);
}
@override
void initState() {
super.initState();
user = Provider.of<UserProvider>(context, listen: false);
dbProvider = Provider.of<DatabaseProvider>(context, listen: false);
}
void fetchGoalPlans() async {
plan = (await dbProvider.userQuery
.subjectGoalPlans(userId: user.id!))[widget.subject.id] ??
'';
setState(() {});
}
@override
Widget build(BuildContext context) {
gradeProvider = Provider.of<GradeProvider>(context);
calculatorProvider = Provider.of<GradeCalculatorProvider>(context);
settingsProvider = Provider.of<SettingsProvider>(context);
List<Grade> subjectGrades = getSubjectGrades(widget.subject).toList();
average = AverageHelper.averageEvals(subjectGrades);
final prevAvg = subjectGrades.isNotEmpty
? AverageHelper.averageEvals(subjectGrades
.where((e) => e.date.isBefore(subjectGrades
.reduce((v, e) => e.date.isAfter(v.date) ? e : v)
.date
.subtract(const Duration(days: 30))))
.toList())
: 0.0;
gradeGraph = Padding(
padding: const EdgeInsets.only(top: 16.0, bottom: 8.0),
child: Panel(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("annual_average".i18n),
if (average != prevAvg)
TrendDisplay(current: average, previous: prevAvg),
],
),
child: Container(
padding: const EdgeInsets.only(top: 16.0, right: 12.0),
child: GradeGraph(subjectGrades,
dayThreshold: 5, classAvg: widget.groupAverage),
),
),
);
if (!gradeCalcMode) {
buildTiles(subjectGrades);
} else {
List<Grade> ghostGrades = calculatorProvider.ghosts
.where((e) => e.subject == widget.subject)
.toList();
buildTiles(ghostGrades);
}
fetchGoalPlans();
return Scaffold(
key: _scaffoldKey,
floatingActionButtonLocation: ExpandableFab.location,
floatingActionButton: Visibility(
visible: !gradeCalcMode &&
subjectGrades
.where((e) => e.type == GradeType.midYear)
.isNotEmpty,
child: ExpandableFab(
type: ExpandableFabType.up,
distance: 50,
childrenOffset: const Offset(-3.8, 0.0),
children: [
FloatingActionButton.small(
heroTag: "btn_ghost_grades",
backgroundColor: Theme.of(context).colorScheme.secondary,
onPressed: () {
gradeCalc(context);
},
child: const Icon(FeatherIcons.plus),
),
FloatingActionButton.small(
heroTag: "btn_goal_planner",
backgroundColor: Theme.of(context).colorScheme.secondary,
onPressed: () {
// if (!Provider.of<PremiumProvider>(context, listen: false)
// .hasScope(PremiumScopes.goalPlanner)) {
// PremiumLockedFeatureUpsell.show(
// context: context, feature: PremiumFeature.goalplanner);
// return;
// }
// ScaffoldMessenger.of(context).showSnackBar(
// const SnackBar(content: Text("Hamarosan...")));
Navigator.of(context).push(CupertinoPageRoute(
builder: (context) =>
GoalPlannerScreen(subject: widget.subject)));
},
child: const Icon(FeatherIcons.flag, size: 20.0),
),
],
),
),
body: RefreshIndicator(
onRefresh: () async {},
color: Theme.of(context).colorScheme.secondary,
child: HeroScrollView(
onClose: () {
if (_sheetController != null && gradeCalcMode) {
_sheetController!.close();
} else {
Navigator.of(context).pop();
}
},
navBarItems: [
const SizedBox(width: 6.0),
if (widget.groupAverage != 0)
Center(
child: AverageDisplay(
average: widget.groupAverage, border: true)),
const SizedBox(width: 6.0),
if (average != 0)
Center(child: AverageDisplay(average: average)),
const SizedBox(width: 6.0),
if (plan != '')
Center(
child: GestureDetector(
onTap: () {
Navigator.of(context).push(CupertinoPageRoute(
builder: (context) =>
GoalStateScreen(subject: widget.subject)));
},
child: Container(
width: 54.0,
padding: const EdgeInsets.symmetric(vertical: 8.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(45.0),
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(.15),
),
child: Icon(
FeatherIcons.flag,
size: 17.0,
weight: 2.5,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
const SizedBox(width: 12.0),
],
icon: SubjectIcon.resolveVariant(
subject: widget.subject, context: context),
scrollController: _scrollController,
title: widget.subject.renamedTo ?? widget.subject.name.capital(),
italic: settingsProvider.renamedSubjectsItalics &&
widget.subject.isRenamed,
child: SubjectGradesContainer(
child: CupertinoScrollbar(
child: ListView.builder(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 24.0),
shrinkWrap: true,
itemBuilder: (context, index) => gradeTiles[index],
itemCount: gradeTiles.length,
),
),
)),
));
}
void gradeCalc(BuildContext context) {
// Scroll to the top of the page
_scrollController.animateTo(100,
duration: const Duration(milliseconds: 500), curve: Curves.ease);
calculatorProvider.clear();
calculatorProvider.addAllGrades(gradeProvider.grades);
_sheetController = _scaffoldKey.currentState?.showBottomSheet(
(context) => RoundedBottomSheet(
borderRadius: 14.0, child: GradeCalculator(widget.subject)),
backgroundColor: const Color(0x00000000),
elevation: 12.0,
);
// Hide the fab and grades
setState(() {
gradeCalcMode = true;
});
_sheetController!.closed.then((value) {
// Show fab and grades
if (mounted) {
setState(() {
gradeCalcMode = false;
});
}
});
}
}

View File

@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:refilc_mobile_ui/pages/grades/grades_count_item.dart';
import 'package:collection/collection.dart';
class GradesCount extends StatelessWidget {
const GradesCount({super.key, required this.grades});
final List<Grade> grades;
@override
Widget build(BuildContext context) {
List<int> gradesCount = List.generate(5,
(int index) => grades.where((e) => e.value.value == index + 1).length);
return Padding(
padding: const EdgeInsets.all(6.0),
child: IntrinsicHeight(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// TODO: make a new widget here, cuz this will not fit
// Text.rich(
// TextSpan(children: [
// TextSpan(
// text: gradesCount.reduce((a, b) => a + b).toString(),
// style: const TextStyle(fontWeight: FontWeight.w600),
// ),
// const TextSpan(
// text: "x",
// style: TextStyle(
// fontSize: 13.0,
// ),
// ),
// ]),
// style: const TextStyle(fontSize: 15.0),
// ),
// const SizedBox(
// width: 10.0,
// ),
// ClipRRect(
// borderRadius: BorderRadius.circular(10.0),
// child: VerticalDivider(
// width: 2,
// thickness: 2,
// indent: 2,
// endIndent: 2,
// color: MediaQuery.of(context).platformBrightness ==
// Brightness.light
// ? Colors.grey.shade300
// : Colors.grey.shade700,
// ),
// ),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: gradesCount
.mapIndexed((index, e) => Padding(
padding: const EdgeInsets.only(left: 9.69),
child: GradesCountItem(count: e, value: index + 1)))
.toList(),
),
const SizedBox(
width: 8.0,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,34 @@
import 'package:refilc/ui/widgets/grade/grade_tile.dart';
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:flutter/material.dart';
class GradesCountItem extends StatelessWidget {
const GradesCountItem({super.key, required this.count, required this.value});
final int count;
final int value;
@override
Widget build(BuildContext context) {
return Row(
children: [
Text.rich(
TextSpan(children: [
TextSpan(
text: count.toString(),
style: const TextStyle(fontWeight: FontWeight.w600),
),
const TextSpan(
text: "x",
style: TextStyle(fontSize: 13.0),
),
]),
style: const TextStyle(fontSize: 15.0),
),
const SizedBox(width: 3.0),
GradeValueWidget(GradeValue(value, "Value", "Value", 100),
size: 18.0, fill: true, shadow: false),
],
);
}
}

View File

@@ -0,0 +1,624 @@
// ignore_for_file: no_leading_underscores_for_local_identifiers
import 'dart:math';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:collection/collection.dart';
import 'package:refilc/api/providers/update_provider.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/ui/widgets/grade/grade_tile.dart';
import 'package:refilc_kreta_api/models/exam.dart';
import 'package:refilc_kreta_api/providers/exam_provider.dart';
// import 'package:refilc_kreta_api/client/api.dart';
// import 'package:refilc_kreta_api/client/client.dart';
import 'package:refilc_kreta_api/providers/grade_provider.dart';
import 'package:refilc/api/providers/user_provider.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:refilc_kreta_api/models/subject.dart';
import 'package:refilc_kreta_api/models/group_average.dart';
import 'package:refilc_kreta_api/providers/homework_provider.dart';
import 'package:refilc_mobile_ui/common/average_display.dart';
import 'package:refilc_mobile_ui/common/bottom_sheet_menu/rounded_bottom_sheet.dart';
import 'package:refilc_mobile_ui/common/empty.dart';
import 'package:refilc_mobile_ui/common/panel/panel.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_button.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_image.dart';
import 'package:refilc_mobile_ui/common/widgets/exam/exam_viewable.dart';
import 'package:refilc_mobile_ui/common/widgets/statistics_tile.dart';
import 'package:refilc_mobile_ui/common/widgets/grade/grade_subject_tile.dart';
import 'package:refilc_mobile_ui/common/trend_display.dart';
import 'package:refilc_mobile_ui/pages/grades/fail_warning.dart';
import 'package:refilc_mobile_ui/pages/grades/grades_count.dart';
import 'package:refilc_mobile_ui/pages/grades/graph.dart';
import 'package:refilc_mobile_ui/pages/grades/grade_subject_view.dart';
import 'package:refilc_plus/models/premium_scopes.dart';
import 'package:refilc_plus/providers/premium_provider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:provider/provider.dart';
import 'package:refilc/helpers/average_helper.dart';
import 'package:refilc_plus/ui/mobile/premium/upsell.dart';
import 'average_selector.dart';
import 'package:refilc_plus/ui/mobile/premium/premium_inline.dart';
import 'calculator/grade_calculator.dart';
import 'calculator/grade_calculator_provider.dart';
import 'grades_page.i18n.dart';
class GradesPage extends StatefulWidget {
const GradesPage({super.key});
@override
GradesPageState createState() => GradesPageState();
}
class GradesPageState extends State<GradesPage> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
PersistentBottomSheetController? _sheetController;
late UserProvider user;
late GradeProvider gradeProvider;
late UpdateProvider updateProvider;
late GradeCalculatorProvider calculatorProvider;
late HomeworkProvider homeworkProvider;
late ExamProvider examProvider;
late String firstName;
late Widget yearlyGraph;
late Widget gradesCount;
List<Widget> subjectTiles = [];
int avgDropValue = 0;
bool gradeCalcMode = false;
List<Grade> getSubjectGrades(GradeSubject subject,
{int days = 0}) =>
!gradeCalcMode
? gradeProvider
.grades
.where((e) =>
e
.subject ==
subject &&
e.type == GradeType.midYear &&
(days ==
0 ||
e.date.isBefore(
DateTime.now().subtract(Duration(days: days)))))
.toList()
: calculatorProvider.grades
.where((e) => e.subject == subject)
.toList();
void generateTiles() {
List<GradeSubject> subjects = gradeProvider.grades
.map((e) => e.subject)
.toSet()
.toList()
..sort((a, b) => a.name.compareTo(b.name));
List<Widget> tiles = [];
Map<GradeSubject, double> subjectAvgs = {};
if (!gradeCalcMode) {
var i = 0;
tiles.addAll(subjects.map((subject) {
List<Grade> subjectGrades = getSubjectGrades(subject);
double avg = AverageHelper.averageEvals(subjectGrades);
double averageBefore = 0.0;
if (avgDropValue != 0) {
List<Grade> gradesBefore =
getSubjectGrades(subject, days: avgDropValue);
averageBefore = avgDropValue == 0
? 0.0
: AverageHelper.averageEvals(gradesBefore);
}
var nullavg = GroupAverage(average: 0.0, subject: subject, uid: "0");
double groupAverage = gradeProvider.groupAverages
.firstWhere((e) => e.subject == subject, orElse: () => nullavg)
.average;
if (avg != 0) subjectAvgs[subject] = avg;
i++;
int homeworkCount = homeworkProvider.homework
.where((e) =>
e.subject.id == subject.id &&
e.deadline.isAfter(DateTime.now()))
.length;
bool hasHomework = homeworkCount > 0;
List<Exam> allExams = examProvider.exams;
allExams.sort((a, b) => a.date.compareTo(b.date));
Exam? nearestExam = allExams.firstWhereOrNull((e) =>
e.subject.id == subject.id && e.writeDate.isAfter(DateTime.now()));
bool hasUnder = hasHomework || nearestExam != null;
return Padding(
padding: i > 1 ? const EdgeInsets.only(top: 9.0) : EdgeInsets.zero,
child: Column(
children: [
Container(
decoration: BoxDecoration(
boxShadow: [
if (Provider.of<SettingsProvider>(context, listen: false)
.shadowEffect)
BoxShadow(
offset: const Offset(0, 21),
blurRadius: 23.0,
color: Theme.of(context).shadowColor,
)
],
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16.0),
topRight: const Radius.circular(16.0),
bottomLeft: hasUnder
? const Radius.circular(8.0)
: const Radius.circular(16.0),
bottomRight: hasUnder
? const Radius.circular(8.0)
: const Radius.circular(16.0),
),
color: Theme.of(context).colorScheme.background,
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0, horizontal: 6.0),
child: Theme(
data: Theme.of(context).copyWith(
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
),
child: GradeSubjectTile(
subject,
averageBefore: averageBefore,
average: avg,
groupAverage: avgDropValue == 0 ? groupAverage : 0.0,
onTap: () {
GradeSubjectView(subject, groupAverage: groupAverage)
.push(context, root: true);
},
),
),
),
),
if (hasUnder)
const SizedBox(
height: 6.0,
),
if (hasHomework)
Container(
decoration: BoxDecoration(
boxShadow: [
if (Provider.of<SettingsProvider>(context, listen: false)
.shadowEffect)
BoxShadow(
offset: const Offset(0, 21),
blurRadius: 23.0,
color: Theme.of(context).shadowColor,
)
],
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(8.0),
topRight: const Radius.circular(8.0),
bottomLeft: nearestExam != null
? const Radius.circular(8.0)
: const Radius.circular(16.0),
bottomRight: nearestExam != null
? const Radius.circular(8.0)
: const Radius.circular(16.0),
),
color: Theme.of(context).colorScheme.background,
),
child: Padding(
padding: const EdgeInsets.only(
top: 8.0,
bottom: 8.0,
left: 15.0,
right: 8.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'you_have_hw'.i18n.fill([homeworkCount]),
style: const TextStyle(
fontSize: 15.0, fontWeight: FontWeight.w500),
),
// const Icon(
// FeatherIcons.chevronRight,
// grade: 0.5,
// size: 20.0,
// )
],
),
),
),
if (nearestExam != null)
Container(
decoration: BoxDecoration(
boxShadow: [
if (Provider.of<SettingsProvider>(context, listen: false)
.shadowEffect)
BoxShadow(
offset: const Offset(0, 21),
blurRadius: 23.0,
color: Theme.of(context).shadowColor,
)
],
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8.0),
topRight: Radius.circular(8.0),
bottomLeft: Radius.circular(16.0),
bottomRight: Radius.circular(16.0),
),
color: Theme.of(context).colorScheme.background,
),
child: ExamViewable(
nearestExam,
showSubject: false,
tilePadding: const EdgeInsets.symmetric(horizontal: 6.0),
),
),
],
),
);
}));
} else {
tiles.clear();
List<Grade> ghostGrades = calculatorProvider.ghosts;
ghostGrades.sort((a, b) => -a.date.compareTo(b.date));
List<GradeTile> _gradeTiles = [];
for (Grade grade in ghostGrades) {
_gradeTiles.add(GradeTile(
grade,
viewOverride: true,
));
}
tiles.add(
_gradeTiles.isNotEmpty
? Panel(
key: ValueKey(gradeCalcMode),
title: Text(
"Ghost Grades".i18n,
),
child: Column(
children: _gradeTiles,
),
)
: const SizedBox(),
);
}
if (tiles.isNotEmpty || gradeCalcMode) {
tiles.insert(0, yearlyGraph);
tiles.insert(1, gradesCount);
if (!gradeCalcMode) {
tiles.insert(2, FailWarning(subjectAvgs: subjectAvgs));
tiles.insert(
3,
PanelTitle(
title: Text(
avgDropValue == 0 ? "Subjects".i18n : "Subjects_changes".i18n,
)),
);
// tiles.insert(4, const PanelHeader(padding: EdgeInsets.only(top: 12.0)));
// tiles.add(const PanelFooter(padding: EdgeInsets.only(bottom: 12.0)));
}
tiles.add(Padding(
padding: EdgeInsets.only(bottom: !gradeCalcMode ? 24.0 : 250.0),
));
} else {
tiles.insert(
0,
Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Empty(subtitle: "empty".i18n),
),
);
}
double subjectAvg = subjectAvgs.isNotEmpty
? subjectAvgs.values.fold(0.0, (double a, double b) => a + b) /
subjectAvgs.length
: 0.0;
final double classAvg = gradeProvider.groupAverages.isNotEmpty
? gradeProvider.groupAverages
.map((e) => e.average)
.fold(0.0, (double a, double b) => a + b) /
gradeProvider.groupAverages.length
: 0.0;
if (subjectAvg > 0 && !gradeCalcMode) {
tiles.add(
PanelTitle(title: Text("data".i18n)),
);
tiles.add(Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: StatisticsTile(
fill: true,
title: AutoSizeText(
"subjectavg".i18n,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
value: subjectAvg,
),
),
const SizedBox(width: 24.0),
Expanded(
child: StatisticsTile(
outline: true,
title: AutoSizeText(
"classavg".i18n,
textAlign: TextAlign.center,
maxLines: 1,
wrapWords: false,
overflow: TextOverflow.ellipsis,
),
value: classAvg,
),
),
],
));
}
tiles.add(Provider.of<PremiumProvider>(context, listen: false).hasPremium
? const SizedBox()
: const Padding(
padding: EdgeInsets.only(top: 24.0),
child: PremiumInline(features: [
PremiumInlineFeature.goal,
PremiumInlineFeature.stats,
]),
));
// padding
tiles.add(const SizedBox(height: 32.0));
subjectTiles = List.castFrom(tiles);
}
@override
Widget build(BuildContext context) {
user = Provider.of<UserProvider>(context);
gradeProvider = Provider.of<GradeProvider>(context);
updateProvider = Provider.of<UpdateProvider>(context);
calculatorProvider = Provider.of<GradeCalculatorProvider>(context);
homeworkProvider = Provider.of<HomeworkProvider>(context);
examProvider = Provider.of<ExamProvider>(context);
context.watch<PremiumProvider>();
List<String> nameParts = user.displayName?.split(" ") ?? ["?"];
firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0];
final double totalClassAvg = gradeProvider.groupAverages.isEmpty
? 0.0
: gradeProvider.groupAverages
.map((e) => e.average)
.fold(0.0, (double a, double b) => a + b) /
gradeProvider.groupAverages.length;
final now = gradeProvider.grades.isNotEmpty
? gradeProvider.grades
.reduce((v, e) => e.date.isAfter(v.date) ? e : v)
.date
: DateTime.now();
final currentStudentAvg = AverageHelper.averageEvals(!gradeCalcMode
? gradeProvider.grades
.where((e) => e.type == GradeType.midYear)
.toList()
: calculatorProvider.grades);
final prevStudentAvg = AverageHelper.averageEvals(gradeProvider.grades
.where((e) => e.type == GradeType.midYear)
.where((e) => e.date.isBefore(now.subtract(const Duration(days: 30))))
.toList());
List<Grade> graphGrades = !gradeCalcMode
? gradeProvider.grades
.where((e) =>
e.type == GradeType.midYear &&
(avgDropValue == 0 ||
e.date.isAfter(
DateTime.now().subtract(Duration(days: avgDropValue)))))
.toList()
: calculatorProvider.grades
.where(((e) =>
avgDropValue == 0 ||
e.date.isAfter(
DateTime.now().subtract(Duration(days: avgDropValue)))))
.toList();
yearlyGraph = Padding(
padding: const EdgeInsets.only(top: 12.0, bottom: 8.0),
child: Panel(
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
AverageSelector(
value: avgDropValue,
onChanged: (value) {
setState(() {
avgDropValue = value!;
});
},
),
Row(
children: [
// if (totalClassAvg >= 1.0) AverageDisplay(average: totalClassAvg, border: true),
// const SizedBox(width: 4.0),
TrendDisplay(
previous: prevStudentAvg, current: currentStudentAvg),
if (gradeProvider.grades
.where((e) => e.type == GradeType.midYear)
.isNotEmpty)
AverageDisplay(average: currentStudentAvg),
],
)
],
),
child: Container(
padding: const EdgeInsets.only(top: 12.0, right: 12.0),
child:
GradeGraph(graphGrades, dayThreshold: 2, classAvg: totalClassAvg),
),
),
);
gradesCount = Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: Panel(child: GradesCount(grades: graphGrades)),
);
generateTiles();
return Scaffold(
key: _scaffoldKey,
body: Padding(
padding: const EdgeInsets.only(top: 9.0),
child: NestedScrollView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics()),
headerSliverBuilder: (context, _) => [
SliverAppBar(
centerTitle: false,
pinned: true,
floating: false,
snap: false,
surfaceTintColor: Theme.of(context).scaffoldBackgroundColor,
actions: [
if (!gradeCalcMode)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 5.0, vertical: 0.0),
child: IconButton(
splashRadius: 24.0,
onPressed: () {
if (!Provider.of<PremiumProvider>(context,
listen: false)
.hasScope(PremiumScopes.totalGradeCalculator)) {
PremiumLockedFeatureUpsell.show(
context: context,
feature: PremiumFeature.gradeCalculation);
return;
}
// SoonAlert.show(context: context);
gradeCalcTotal(context);
},
icon: Icon(
FeatherIcons.plus,
color: AppColors.of(context).text,
),
),
),
// profile Icon
Padding(
padding: const EdgeInsets.only(right: 24.0),
child: ProfileButton(
child: ProfileImage(
heroTag: "profile",
name: firstName,
backgroundColor: Theme.of(context)
.colorScheme
.primary, //ColorUtils.stringToColor(user.displayName ?? "?"),
badge: updateProvider.available,
role: user.role,
profilePictureString: user.picture,
),
),
),
],
automaticallyImplyLeading: false,
title: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
"Grades".i18n,
style: TextStyle(
color: AppColors.of(context).text,
fontSize: 32.0,
fontWeight: FontWeight.bold),
),
),
shadowColor: Theme.of(context).shadowColor,
),
],
body: RefreshIndicator(
onRefresh: () => gradeProvider.fetch(),
color: Theme.of(context).colorScheme.secondary,
child: ListView.builder(
padding: EdgeInsets.zero,
physics: const BouncingScrollPhysics(),
itemCount: max(subjectTiles.length, 1),
itemBuilder: (context, index) {
if (subjectTiles.isNotEmpty) {
EdgeInsetsGeometry panelPadding =
const EdgeInsets.symmetric(horizontal: 24.0);
if (subjectTiles[index].runtimeType == GradeSubjectTile) {
return Padding(
padding: panelPadding,
child: PanelBody(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: subjectTiles[index],
));
} else {
return Padding(
padding: panelPadding, child: subjectTiles[index]);
}
} else {
return Container();
}
},
),
),
),
),
);
}
void gradeCalcTotal(BuildContext context) {
calculatorProvider.clear();
calculatorProvider.addAllGrades(gradeProvider.grades);
_sheetController = _scaffoldKey.currentState?.showBottomSheet(
(context) => const RoundedBottomSheet(
borderRadius: 14.0, child: GradeCalculator(null)),
backgroundColor: const Color(0x00000000),
elevation: 12.0,
);
// Hide the fab and grades
setState(() {
gradeCalcMode = true;
});
_sheetController!.closed.then((value) {
// Show fab and grades
if (mounted) {
setState(() {
gradeCalcMode = false;
});
}
});
}
}

View File

@@ -0,0 +1,66 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"Grades": "Subjects",
"Ghost Grades": "Grades",
"Subjects": "Your Subjects",
"Subjects_changes": "Subject Differences",
"empty": "You don't have any subjects.",
"annual_average": "Annual average",
"3_months_average": "3 Monthly Average",
"30_days_average": "Monthly Average",
"14_days_average": "2 Weekly Average",
"7_days_average": "Weekly Average",
"subjectavg": "Subject Average",
"classavg": "Class Average",
"fail_warning": "Failure warning",
"fail_warning_description": "You are failing %d subject(s)",
"data": "Data",
"you_have_hw": "You have %s homework(s) to do",
},
"hu_hu": {
"Grades": "Tantárgyak",
"Ghost Grades": "Szellem jegyek",
"Subjects": "Tantárgyaid",
"Subjects_changes": "Tantárgyi változások",
"empty": "Még nincs egy tárgyad sem.",
"annual_average": "Éves átlag",
"3_months_average": "Háromhavi átlag",
"30_days_average": "Havi átlag",
"14_days_average": "Kétheti átlag",
"7_days_average": "Heti átlag",
"subjectavg": "Tantárgyi átlag",
"classavg": "Osztályátlag",
"fail_warning": "Bukás figyelmeztető",
"fail_warning_description": "Bukásra állsz %d tantárgyból",
"data": "Adatok",
"you_have_hw": "%s házi feladat vár rád",
},
"de_de": {
"Grades": "Fächer",
"Ghost Grades": "Geist Noten",
"Subjects": "Ihre Themen",
"Subjects_changes": "Betreff Änderungen",
"empty": "Sie haben keine Fächer.",
"annual_average": "Jahresdurchschnitt",
"3_months_average": "Drei-Monats-Durchschnitt",
"30_days_average": "Monatsdurchschnitt",
"14_days_average": "Vierzehntägiger Durchschnitt",
"7_days_average": "Wöchentlicher Durchschnitt",
"subjectavg": "Fächer Durchschnitt",
"classavg": "Klassendurchschnitt",
"fail_warning": "Ausfallwarnung",
"fail_warning_description": "Sie werden in %d des Fachs durchfallen",
"data": "Daten",
"you_have_hw": "Du hast %s Hausaufgaben",
},
};
String get i18n => localize(this, _t);
String fill(List<Object> params) => localizeFill(this, params);
String plural(int value) => localizePlural(value, this, _t);
String version(Object modifier) => localizeVersion(modifier, this, _t);
}

View File

@@ -0,0 +1,377 @@
import 'dart:math';
import 'package:refilc/helpers/average_helper.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:intl/intl.dart';
import 'package:i18n_extension/i18n_widget.dart';
import 'package:provider/provider.dart';
import 'graph.i18n.dart';
class GradeGraph extends StatefulWidget {
const GradeGraph(this.data,
{super.key, this.dayThreshold = 7, this.classAvg});
final List<Grade> data;
final int dayThreshold;
final double? classAvg;
@override
GradeGraphState createState() => GradeGraphState();
}
class GradeGraphState extends State<GradeGraph> {
late SettingsProvider settings;
List<Color> getColors(List<Grade> data) {
List<Color> colors = [];
List<List<Grade>> sortedData = [[]];
// Sort by date descending
data.sort((a, b) => -a.writeDate.compareTo(b.writeDate));
// Sort data to points by treshold
for (var element in data) {
if (sortedData.last.isNotEmpty &&
sortedData.last.last.writeDate.difference(element.writeDate).inDays >
widget.dayThreshold) {
sortedData.add([]);
}
for (var dataList in sortedData) {
dataList.add(element);
}
}
// Create FlSpots from points
for (var dataList in sortedData) {
double average = AverageHelper.averageEvals(dataList);
Color clr = average >= 1 && average <= 5
? ColorTween(
begin: settings.gradeColors[average.floor() - 1],
end: settings.gradeColors[average.ceil() - 1])
.transform(average - average.floor())!
: Theme.of(context).colorScheme.secondary;
colors.add(clr);
}
return colors;
}
List<FlSpot> getSpots(List<Grade> data) {
List<FlSpot> subjectData = [];
List<List<Grade>> sortedData = [[]];
// Sort by date descending
data.sort((a, b) => -a.writeDate.compareTo(b.writeDate));
// Sort data to points by treshold
for (var element in data) {
if (sortedData.last.isNotEmpty &&
sortedData.last.last.writeDate.difference(element.writeDate).inDays >
widget.dayThreshold) {
sortedData.add([]);
}
for (var dataList in sortedData) {
dataList.add(element);
}
}
// Create FlSpots from points
for (var dataList in sortedData) {
double average = AverageHelper.averageEvals(dataList);
if (dataList.isNotEmpty) {
subjectData.add(FlSpot(
dataList[0].writeDate.month +
(dataList[0].writeDate.day / 31) +
((dataList[0].writeDate.year - data.last.writeDate.year) * 12),
double.parse(average.toStringAsFixed(2)),
));
}
}
return subjectData;
}
@override
Widget build(BuildContext context) {
settings = Provider.of<SettingsProvider>(context);
List<FlSpot> subjectSpots = [];
List<FlSpot> ghostSpots = [];
List<VerticalLine> extraLinesV = [];
List<HorizontalLine> extraLinesH = [];
// Filter data
List<Grade> data = widget.data
.where((e) => e.value.weight != 0)
.where((e) => e.type == GradeType.midYear)
.where((e) => e.gradeType?.name == "Osztalyzat")
.toList();
// Filter ghost data
List<Grade> ghostData = widget.data
.where((e) => e.value.weight != 0)
.where((e) => e.type == GradeType.ghost)
.toList();
// Calculate average
double average = AverageHelper.averageEvals(data);
// Calculate graph color
Color averageColor = average >= 1 && average <= 5
? ColorTween(
begin: settings.gradeColors[average.floor() - 1],
end: settings.gradeColors[average.ceil() - 1])
.transform(average - average.floor())!
: Theme.of(context).colorScheme.secondary;
List<Color> averageColors = getColors(data);
subjectSpots = getSpots(data);
// naplo/#73
if (subjectSpots.isNotEmpty) {
ghostSpots = getSpots(data + ghostData);
// hax
ghostSpots = ghostSpots
.where((e) => e.x >= subjectSpots.map((f) => f.x).reduce(max))
.toList();
ghostSpots = ghostSpots.map((e) => FlSpot(e.x + 0.1, e.y)).toList();
ghostSpots.add(subjectSpots.firstWhere(
(e) => e.x >= subjectSpots.map((f) => f.x).reduce(max),
orElse: () => const FlSpot(-1, -1)));
ghostSpots.removeWhere(
(element) => element.x == -1 && element.y == -1); // naplo/#74
}
Grade halfYearGrade = widget.data.lastWhere(
(e) => e.type == GradeType.halfYear,
orElse: () => Grade.fromJson({}));
if (halfYearGrade.date.year != 0 && data.isNotEmpty) {
final maxX = ghostSpots.isNotEmpty ? ghostSpots.first.x : 0;
final x = halfYearGrade.writeDate.month +
(halfYearGrade.writeDate.day / 31) +
((halfYearGrade.writeDate.year - data.last.writeDate.year) * 12);
if (x <= maxX) {
extraLinesV.add(
VerticalLine(
x: x,
strokeWidth: 3.0,
color: AppColors.of(context).red.withOpacity(.75),
label: VerticalLineLabel(
labelResolver: (_) => " ${"mid".i18n} ", // <- zwsp for padding
show: true,
alignment: Alignment.topLeft,
style: TextStyle(
backgroundColor: Theme.of(context).colorScheme.background,
color: AppColors.of(context).text,
fontSize: 16.0,
fontWeight: FontWeight.w600,
),
),
),
);
}
}
// Horizontal line displaying the class average
if (widget.classAvg != null &&
widget.classAvg! > 0.0 &&
settings.graphClassAvg) {
extraLinesH.add(HorizontalLine(
y: widget.classAvg!,
color: AppColors.of(context).text.withOpacity(.75),
));
}
// LineChart is really cute because it tries to render it's contents outside of it's rect.
return widget.data.length <= 2
? SizedBox(
height: 150,
child: Center(
child: Text(
"not_enough_grades".i18n,
textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
)
: ClipRect(
child: SizedBox(
height: 158,
child: subjectSpots.length > 1
? Padding(
padding: const EdgeInsets.only(top: 8.0, right: 8.0),
child: LineChart(
LineChartData(
extraLinesData: ExtraLinesData(
verticalLines: extraLinesV,
horizontalLines: extraLinesH),
lineBarsData: [
LineChartBarData(
preventCurveOverShooting: true,
spots: subjectSpots,
isCurved: true,
colors: averageColors,
barWidth: 8,
isStrokeCapRound: true,
dotData: FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
colors: [
averageColor.withOpacity(0.7),
averageColor.withOpacity(0.3),
averageColor.withOpacity(0.2),
averageColor.withOpacity(0.1),
],
gradientColorStops: [0.1, 0.6, 0.8, 1],
gradientFrom: const Offset(0, 0),
gradientTo: const Offset(0, 1),
),
),
if (ghostData.isNotEmpty && ghostSpots.isNotEmpty)
LineChartBarData(
preventCurveOverShooting: true,
spots: ghostSpots,
isCurved: true,
colors: [AppColors.of(context).text],
barWidth: 8,
isStrokeCapRound: true,
dotData: FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
colors: [
AppColors.of(context).text.withOpacity(0.7),
AppColors.of(context).text.withOpacity(0.3),
AppColors.of(context).text.withOpacity(0.2),
AppColors.of(context).text.withOpacity(0.1),
],
gradientColorStops: [0.1, 0.6, 0.8, 1],
gradientFrom: const Offset(0, 0),
gradientTo: const Offset(0, 1),
),
),
],
minY: 1,
maxY: 5,
gridData: FlGridData(
show: true,
horizontalInterval: 1,
// checkToShowVerticalLine: (_) => false,
// getDrawingHorizontalLine: (_) => FlLine(
// color: AppColors.of(context).text.withOpacity(.15),
// strokeWidth: 2,
// ),
// getDrawingVerticalLine: (_) => FlLine(
// color: AppColors.of(context).text.withOpacity(.25),
// strokeWidth: 2,
// ),
),
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
tooltipBgColor: Colors.grey.shade800,
fitInsideVertically: true,
fitInsideHorizontally: true,
),
handleBuiltInTouches: true,
touchSpotThreshold: 20.0,
getTouchedSpotIndicator: (_, spots) {
return List.generate(
spots.length,
(index) => TouchedSpotIndicatorData(
FlLine(
color: Colors.grey.shade900,
strokeWidth: 3.5,
),
FlDotData(
getDotPainter: (a, b, c, d) =>
FlDotCirclePainter(
strokeWidth: 0,
color: Colors.grey.shade900,
radius: 10.0,
),
),
),
);
},
),
borderData: FlBorderData(
show: false,
border: Border.all(
color: Theme.of(context).scaffoldBackgroundColor,
width: 4,
),
),
titlesData: FlTitlesData(
bottomTitles: SideTitles(
showTitles: true,
reservedSize: 24,
getTextStyles: (context, value) => TextStyle(
color:
AppColors.of(context).text.withOpacity(.75),
fontWeight: FontWeight.bold,
fontSize: 14.0,
),
margin: 12.0,
getTitles: (value) {
var format = DateFormat(
"MMM", I18n.of(context).locale.toString());
String title = format
.format(DateTime(0, value.floor() % 12))
.replaceAll(".", "");
title =
title.substring(0, min(title.length, 4));
return title.toUpperCase();
},
interval: () {
List<Grade> tData =
ghostData.isNotEmpty ? ghostData : data;
tData.sort((a, b) =>
a.writeDate.compareTo(b.writeDate));
return tData.first.writeDate
.add(const Duration(days: 120))
.isBefore(tData.last.writeDate)
? 2.0
: 1.0;
}(),
checkToShowTitle: (double minValue,
double maxValue,
SideTitles sideTitles,
double appliedInterval,
double value) {
if (value == maxValue || value == minValue) {
return false;
}
return true;
},
),
leftTitles: SideTitles(
showTitles: true,
interval: 1.0,
getTextStyles: (context, value) => TextStyle(
color: AppColors.of(context).text,
fontWeight: FontWeight.bold,
fontSize: 18.0,
),
margin: 16,
),
rightTitles: SideTitles(showTitles: false),
topTitles: SideTitles(showTitles: false),
),
),
),
)
: null,
),
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"mid": "Mid year",
"not_enough_grades": "Not enough data to show a graph here.",
},
"hu_hu": {
"mid": "Félév",
"not_enough_grades": "Nem szereztél még elég jegyet grafikon mutatáshoz.",
},
"de_de": {
"mid": "Halbjährlich",
"not_enough_grades": "Noch nicht genug Noten, um die Grafik zu zeigen.",
},
};
String get i18n => localize(this, _t);
String fill(List<Object> params) => localizeFill(this, params);
String plural(int value) => localizePlural(value, this, _t);
String version(Object modifier) => localizeVersion(modifier, this, _t);
}

View File

@@ -0,0 +1,11 @@
import 'package:flutter/material.dart';
class SubjectGradesContainer extends InheritedWidget {
const SubjectGradesContainer({super.key, required super.child});
static SubjectGradesContainer? of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<SubjectGradesContainer>();
@override
bool updateShouldNotify(SubjectGradesContainer oldWidget) => false;
}

View File

@@ -0,0 +1,457 @@
// ignore_for_file: dead_code
import 'dart:math';
import 'package:refilc/api/providers/live_card_provider.dart';
import 'package:refilc/ui/date_widget.dart';
import 'package:refilc/utils/format.dart';
import 'package:i18n_extension/i18n_widget.dart';
import 'package:intl/intl.dart';
import 'package:refilc_plus/providers/premium_provider.dart';
import 'package:animated_list_plus/animated_list_plus.dart';
import 'package:refilc/api/providers/update_provider.dart';
import 'package:refilc/api/providers/sync.dart';
import 'package:confetti/confetti.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc_kreta_api/providers/absence_provider.dart';
import 'package:refilc_kreta_api/providers/event_provider.dart';
import 'package:refilc_kreta_api/providers/exam_provider.dart';
import 'package:refilc_kreta_api/providers/grade_provider.dart';
import 'package:refilc_kreta_api/providers/homework_provider.dart';
import 'package:refilc_kreta_api/providers/message_provider.dart';
import 'package:refilc_kreta_api/providers/note_provider.dart';
import 'package:refilc/api/providers/user_provider.dart';
import 'package:refilc/api/providers/status_provider.dart';
import 'package:refilc_kreta_api/providers/timetable_provider.dart';
import 'package:refilc_mobile_ui/common/empty.dart';
import 'package:refilc_mobile_ui/common/filter_bar.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_button.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_image.dart';
import 'package:refilc_mobile_ui/pages/home/live_card/live_card.dart';
import 'package:refilc_mobile_ui/screens/navigation/navigation_screen.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'home_page.i18n.dart';
import 'package:refilc/ui/filter/widgets.dart';
import 'package:refilc/ui/filter/sort.dart';
import 'package:i18n_extension/i18n_extension.dart';
// import 'package:dropdown_button2/dropdown_button2.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
HomePageState createState() => HomePageState();
}
class HomePageState extends State<HomePage> with TickerProviderStateMixin {
late TabController _tabController;
late UserProvider user;
late SettingsProvider settings;
late UpdateProvider updateProvider;
late StatusProvider statusProvider;
late GradeProvider gradeProvider;
late TimetableProvider timetableProvider;
late MessageProvider messageProvider;
late AbsenceProvider absenceProvider;
late HomeworkProvider homeworkProvider;
late ExamProvider examProvider;
late NoteProvider noteProvider;
late EventProvider eventProvider;
late PageController _pageController;
ConfettiController? _confettiController;
late LiveCardProvider _liveCard;
late AnimationController _liveCardAnimation;
late String greeting;
late String firstName;
late List<String> listOrder;
static const pageCount = 5;
@override
void initState() {
super.initState();
_tabController = TabController(length: pageCount, vsync: this);
_pageController = PageController();
user = Provider.of<UserProvider>(context, listen: false);
_liveCard = Provider.of<LiveCardProvider>(context, listen: false);
_liveCardAnimation = AnimationController(
vsync: this, duration: const Duration(milliseconds: 500));
_liveCardAnimation.animateTo(_liveCard.show ? 1.0 : 0.0,
duration: Duration.zero);
listOrder = List.generate(pageCount, (index) => "$index");
}
@override
void dispose() {
// _filterController.dispose();
_pageController.dispose();
_tabController.dispose();
_confettiController?.dispose();
_liveCardAnimation.dispose();
super.dispose();
}
void setGreeting() {
DateTime now = DateTime.now();
List<String> nameParts = user.displayName?.split(" ") ?? ["?"];
if (!settings.presentationMode) {
firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0];
} else {
firstName = "János";
}
bool customWelcome = false;
if (now.isBefore(DateTime(now.year, DateTime.august, 31)) &&
now.isAfter(DateTime(now.year, DateTime.june, 14))) {
greeting = "goodrest";
if (NavigationScreen.of(context)?.init("confetti") ?? false) {
_confettiController =
ConfettiController(duration: const Duration(seconds: 1));
Future.delayed(const Duration(seconds: 1))
.then((value) => mounted ? _confettiController?.play() : null);
}
} else if (now.month == user.student?.birth.month &&
now.day == user.student?.birth.day) {
greeting = "happybirthday";
if (NavigationScreen.of(context)?.init("confetti") ?? false) {
_confettiController =
ConfettiController(duration: const Duration(seconds: 3));
Future.delayed(const Duration(seconds: 1))
.then((value) => mounted ? _confettiController?.play() : null);
}
} else if (now.isAfter(DateTime(now.year, DateTime.may, 28)) &&
now.isBefore(DateTime(now.year, DateTime.may, 30))) {
greeting = "refilcopen";
if (NavigationScreen.of(context)?.init("confetti") ?? false) {
_confettiController =
ConfettiController(duration: const Duration(seconds: 3));
Future.delayed(const Duration(seconds: 1))
.then((value) => mounted ? _confettiController?.play() : null);
}
} else if (now.month == DateTime.december &&
now.day >= 24 &&
now.day <= 26) {
greeting = "merryxmas";
} else if (now.month == DateTime.january && now.day == 1) {
greeting = "happynewyear";
} else if (settings.welcomeMessage.replaceAll(' ', '') != '') {
greeting = settings.welcomeMessage;
greeting = localizeFill(
settings.welcomeMessage,
[firstName],
);
customWelcome = true;
} else if (now.hour >= 18) {
greeting = "goodevening";
} else if (now.hour >= 10) {
greeting = "goodafternoon";
} else if (now.hour >= 4) {
greeting = "goodmorning";
} else {
greeting = "goodevening";
}
greeting = customWelcome ? greeting : greeting.i18n.fill([firstName]);
}
@override
Widget build(BuildContext context) {
user = Provider.of<UserProvider>(context);
settings = Provider.of<SettingsProvider>(context);
statusProvider = Provider.of<StatusProvider>(context, listen: false);
updateProvider = Provider.of<UpdateProvider>(context);
_liveCard = Provider.of<LiveCardProvider>(context);
gradeProvider = Provider.of<GradeProvider>(context);
context.watch<PremiumProvider>();
_liveCardAnimation.animateTo(_liveCard.show ? 1.0 : 0.0);
setGreeting();
//for extra filters
// final List<String> items = [
// 'Item1',
// 'Item2',
// 'Item3',
// 'Item4',
// 'Item5',
// 'Item6',
// 'Item7',
// 'Item8',
// ];
// String? selectedValue;
return Scaffold(
body: Stack(
children: [
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: NestedScrollView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics()),
headerSliverBuilder: (context, _) => [
AnimatedBuilder(
animation: _liveCardAnimation,
builder: (context, child) {
return SliverAppBar(
automaticallyImplyLeading: false,
surfaceTintColor:
Theme.of(context).scaffoldBackgroundColor,
centerTitle: false,
titleSpacing: 0.0,
// Welcome text
title: Padding(
padding: const EdgeInsets.only(left: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
greeting,
overflow: TextOverflow.fade,
textAlign: TextAlign.start,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 18.0,
color: Theme.of(context)
.textTheme
.bodyMedium
?.color,
),
),
Text(
DateFormat('EEEE, MMM d',
I18n.locale.countryCode)
.format(DateTime.now())
.capital(),
textAlign: TextAlign.start,
style: const TextStyle(
fontSize: 13.0,
fontWeight: FontWeight.w500,
),
),
],
),
),
actions: [
// Profile Icon
Padding(
padding: const EdgeInsets.only(right: 24.0),
child: ProfileButton(
child: ProfileImage(
heroTag: "profile",
name: firstName,
backgroundColor: Theme.of(context)
.colorScheme
.primary, //!settings.presentationMode
//? ColorUtils.stringToColor(user.displayName ?? "?")
//: Theme.of(context).colorScheme.secondary,
badge: updateProvider.available,
role: user.role,
profilePictureString: user.picture,
),
),
),
],
expandedHeight: _liveCardAnimation.value * 238.0,
// Live Card
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: EdgeInsets.only(
left: 24.0,
right: 24.0,
top:
62.0 + MediaQuery.of(context).padding.top,
bottom: 52.0,
),
child: Transform.scale(
scale: _liveCardAnimation.value,
child: Opacity(
opacity: _liveCardAnimation.value,
child: const LiveCard(),
),
),
),
),
shadowColor: Colors.black,
// Filter Bar
bottom: FilterBar(
items: [
Tab(text: "All".i18n),
Tab(text: "Grades".i18n),
Tab(text: "Exams".i18n),
Tab(text: "Messages".i18n),
Tab(text: "Absences".i18n),
],
controller: _tabController,
disableFading: true,
onTap: (i) async {
int selectedPage =
_pageController.page!.round();
if (i == selectedPage) return;
if (_pageController.page?.roundToDouble() !=
_pageController.page) {
_pageController.animateToPage(i,
curve: Curves.easeIn,
duration: kTabScrollDuration);
return;
}
// swap current page with target page
setState(() {
_pageController.jumpToPage(i);
String currentList = listOrder[selectedPage];
listOrder[selectedPage] = listOrder[i];
listOrder[i] = currentList;
});
},
),
pinned: true,
floating: false,
snap: false,
);
},
),
],
body: Padding(
padding: const EdgeInsets.only(top: 12.0),
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
// from flutter source
if (notification is ScrollUpdateNotification &&
!_tabController.indexIsChanging) {
if ((_pageController.page! - _tabController.index)
.abs() >
1.0) {
_tabController.index = _pageController.page!.floor();
}
_tabController.offset =
(_pageController.page! - _tabController.index)
.clamp(-1.0, 1.0);
} else if (notification is ScrollEndNotification) {
_tabController.index = _pageController.page!.round();
if (!_tabController.indexIsChanging) {
_tabController.offset =
(_pageController.page! - _tabController.index)
.clamp(-1.0, 1.0);
}
}
return false;
},
child: PageView.custom(
controller: _pageController,
childrenDelegate: SliverChildBuilderDelegate(
(BuildContext context, int index) {
return FutureBuilder<List<DateWidget>>(
key: ValueKey<String>(listOrder[index]),
future: getFilterWidgets(homeFilters[index],
context: context),
builder: (context, dateWidgets) => dateWidgets
.data !=
null
? RefreshIndicator(
color:
Theme.of(context).colorScheme.secondary,
onRefresh: () => syncAll(context),
child: ImplicitlyAnimatedList<Widget>(
items: [
if (index == 0)
const SizedBox(key: Key("\$premium")),
...sortDateWidgets(context,
dateWidgets: dateWidgets.data!,
padding: EdgeInsets.zero),
],
itemBuilder: filterItemBuilder,
spawnIsolate: false,
areItemsTheSame: (a, b) => a.key == b.key,
physics: const BouncingScrollPhysics(
parent:
AlwaysScrollableScrollPhysics()),
padding: const EdgeInsets.symmetric(
horizontal: 24.0),
))
: Container(),
);
},
childCount: 5,
findChildIndexCallback: (Key key) {
final ValueKey<String> valueKey =
key as ValueKey<String>;
final String data = valueKey.value;
return listOrder.indexOf(data);
},
),
physics: const PageScrollPhysics()
.applyTo(const BouncingScrollPhysics()),
),
),
)),
),
// confetti 🎊
if (_confettiController != null)
Align(
alignment: Alignment.topCenter,
child: ConfettiWidget(
confettiController: _confettiController!,
blastDirectionality: BlastDirectionality.explosive,
emissionFrequency: 0.02,
numberOfParticles: 120,
maxBlastForce: 20,
minBlastForce: 10,
gravity: 0.3,
minimumSize: const Size(5, 5),
maximumSize: const Size(20, 20),
),
),
],
),
);
}
Future<Widget> filterViewBuilder(context, int activeData) async {
final activeFilter = homeFilters[activeData];
List<Widget> filterWidgets = sortDateWidgets(
context,
dateWidgets: await getFilterWidgets(activeFilter, context: context),
showDivider: true,
);
return Padding(
padding: const EdgeInsets.only(top: 12.0),
child: RefreshIndicator(
color: Theme.of(context).colorScheme.secondary,
onRefresh: () => syncAll(context),
child: ListView.builder(
padding: EdgeInsets.zero,
physics: const BouncingScrollPhysics(),
itemBuilder: (context, index) {
if (filterWidgets.isNotEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: filterWidgets[index],
);
} else {
return Empty(subtitle: "empty".i18n);
}
},
itemCount: max(filterWidgets.length, 1),
),
),
);
}
}

View File

@@ -0,0 +1,72 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"goodmorning": "Good morning, %s!",
"goodafternoon": "Good afternoon, %s!",
"goodevening": "Good evening, %s!",
"goodrest": "⛱️ Have a nice holiday, %s!",
"happybirthday": "🎂 Happy birthday, %s!",
"merryxmas": "🎄 Merry Christmas, %s!",
"happynewyear": "🎉 Happy New Year, %s!",
"refilcopen": "🎈 reFilc is 1 year old, %s!",
"empty": "Nothing to see here.",
"All": "All",
"Grades": "Grades",
"Exams": "Exams",
"Messages": "Messages",
"Absences": "Absences",
"update_available": "Update Available",
"missed_exams": "You missed %s exams this week."
.one("You missed an exam this week."),
"missed_exam_contact": "Contact %s, to resolve it!",
},
"hu_hu": {
"goodmorning": "Jó reggelt, %s!",
"goodafternoon": "Szép napot, %s!",
"goodevening": "Szép estét, %s!",
"goodrest": "⛱️ Jó szünetet, %s!",
"happybirthday": "🎂 Boldog születésnapot, %s!",
"merryxmas": "🎄 Boldog Karácsonyt, %s!",
"happynewyear": "🎉 Boldog új évet, %s!",
"refilcopen": "🎈 1 éves a reFilc, %s!",
"empty": "Nincs itt semmi látnivaló.",
"All": "Összes",
"Grades": "Jegyek",
"Exams": "Számonkérések",
"Messages": "Üzenetek",
"Absences": "Hiányzások",
"update_available": "Frissítés elérhető",
"missed_exams": "Ezen a héten hiányoztál %s dolgozatról."
.one("Ezen a héten hiányoztál egy dolgozatról."),
"missed_exam_contact": "Keresd %s-t, ha pótolni szeretnéd!",
},
"de_de": {
"goodmorning": "Guten morgen, %s!",
"goodafternoon": "Guten Tag, %s!",
"goodevening": "Guten Abend, %s!",
"goodrest": "⛱️ Schöne Ferien, %s!",
"happybirthday": "🎂 Alles Gute zum Geburtstag, %s!",
"merryxmas": "🎄 Frohe Weihnachten, %s!",
"happynewyear": "🎉 Frohes neues Jahr, %s!",
"refilcopen": "🎈 reFilc ist 1 Jahr alt, %s!",
"empty": "Hier gibt es nichts zu sehen.",
"All": "Alles",
"Grades": "Noten",
"Exams": "Aufsätze",
"Messages": "Nachrichten",
"Absences": "Fehlen",
"update_available": "Update verfügbar",
"missed_exams": "Diese Woche haben Sie %s Prüfungen verpasst."
.one("Diese Woche haben Sie eine Prüfung verpasst."),
"missed_exam_contact": "Wenden Sie sich an %s, um sie zu erneuern!",
},
};
String get i18n => localize(this, _t);
String fill(List<Object> params) => localizeFill(this, params);
String plural(int value) => localizePlural(value, this, _t);
String version(Object modifier) => localizeVersion(modifier, this, _t);
}

View File

@@ -0,0 +1,104 @@
import 'dart:async';
import 'package:animated_flip_counter/animated_flip_counter.dart';
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
class HeadsUpCountdown extends StatefulWidget {
const HeadsUpCountdown(
{super.key, required this.maxTime, required this.elapsedTime});
final double maxTime;
final double elapsedTime;
@override
State<HeadsUpCountdown> createState() => _HeadsUpCountdownState();
}
class _HeadsUpCountdownState extends State<HeadsUpCountdown> {
static const _style = TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 70.0,
letterSpacing: -.5,
);
late final Timer _timer;
late double elapsed;
@override
void initState() {
super.initState();
elapsed = widget.elapsedTime;
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (elapsed <= widget.maxTime) elapsed += 1;
setState(() {});
if (elapsed >= widget.maxTime) {
Future.delayed(const Duration(seconds: 5), () {
if (mounted) Navigator.of(context).pop();
});
}
});
}
@override
void dispose() {
_timer.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final dur = Duration(seconds: (widget.maxTime - elapsed).round());
return Center(
child: Material(
type: MaterialType.transparency,
child: Stack(
alignment: Alignment.center,
children: [
AnimatedOpacity(
opacity: dur.inSeconds > 0 ? 1.0 : 0.0,
duration: const Duration(milliseconds: 500),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if ((dur.inHours % 24) > 0) ...[
AnimatedFlipCounter(
value: dur.inHours % 24,
curve: Curves.fastLinearToSlowEaseIn,
textStyle: _style,
),
const Text(":", style: _style),
],
AnimatedFlipCounter(
duration: const Duration(seconds: 2),
value: dur.inMinutes % 60,
curve: Curves.fastLinearToSlowEaseIn,
wholeDigits: (dur.inHours % 24) > 0 ? 2 : 1,
textStyle: _style,
),
const Text(":", style: _style),
AnimatedFlipCounter(
duration: const Duration(seconds: 1),
value: dur.inSeconds % 60,
curve: Curves.fastLinearToSlowEaseIn,
wholeDigits: 2,
textStyle: _style,
),
],
),
),
if (dur.inSeconds < 0)
AnimatedOpacity(
opacity: dur.inSeconds > 0 ? 0.0 : 1.0,
duration: const Duration(milliseconds: 500),
child: Lottie.asset("assets/animations/bell-alert.json",
width: 400),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,305 @@
import 'package:animations/animations.dart';
import 'package:refilc/api/providers/user_provider.dart';
import 'package:refilc/helpers/subject.dart';
import 'package:refilc/icons/filc_icons.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc_mobile_ui/pages/home/live_card/heads_up_countdown.dart';
import 'package:refilc_mobile_ui/screens/summary/summary_screen.dart';
import 'package:flutter/material.dart';
import 'package:refilc/utils/format.dart';
import 'package:refilc/api/providers/live_card_provider.dart';
import 'package:refilc_mobile_ui/pages/home/live_card/live_card_widget.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:i18n_extension/i18n_widget.dart';
import 'package:intl/intl.dart';
import 'package:maps_launcher/maps_launcher.dart';
import 'package:provider/provider.dart';
import 'live_card.i18n.dart';
class LiveCard extends StatefulWidget {
const LiveCard({super.key});
@override
LiveCardStateA createState() => LiveCardStateA();
}
class LiveCardStateA extends State<LiveCard> {
late void Function() listener;
late UserProvider _userProvider;
late LiveCardProvider liveCard;
@override
void initState() {
super.initState();
listener = () => setState(() {});
_userProvider = Provider.of<UserProvider>(context, listen: false);
liveCard = Provider.of<LiveCardProvider>(context, listen: false);
_userProvider.addListener(liveCard.update);
}
@override
void dispose() {
_userProvider.removeListener(liveCard.update);
super.dispose();
}
@override
Widget build(BuildContext context) {
liveCard = Provider.of<LiveCardProvider>(context);
SettingsProvider settingsProvider = Provider.of<SettingsProvider>(context);
if (!liveCard.show) return Container();
Widget child;
Duration bellDelay = liveCard.delay;
// test
// liveCard.currentState = LiveCardState.morning;
switch (liveCard.currentState) {
case LiveCardState.summary:
child = LiveCardWidget(
key: const Key('livecard.summary'),
title: 'Vége a tanévnek! 🥳',
icon: FeatherIcons.arrowRight,
description: Text(
'Irány az összefoglaláshoz',
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 18.0,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
onTap: () {
// showSlidingBottomSheet(
// context,
// useRootNavigator: true,
// builder: (context) => SlidingSheetDialog(
// color: Colors.black.withOpacity(0.99),
// duration: const Duration(milliseconds: 400),
// scrollSpec: const ScrollSpec.bouncingScroll(),
// snapSpec: const SnapSpec(
// snap: true,
// snappings: [1.0],
// initialSnap: 1.0,
// positioning: SnapPositioning.relativeToAvailableSpace,
// ),
// minHeight: MediaQuery.of(context).size.height,
// cornerRadius: 16,
// cornerRadiusOnFullscreen: 0,
// builder: (context, state) => const Material(
// color: Colors.black,
// child: SummaryScreen(
// currentPage: 'start',
// ),
// ),
// ),
// );
SummaryScreen.show(context: context, currentPage: 'start');
},
);
break;
case LiveCardState.morning:
child = LiveCardWidget(
key: const Key('livecard.morning'),
title: DateFormat("EEEE", I18n.of(context).locale.toString())
.format(DateTime.now())
.capital(),
icon: FeatherIcons.sun,
onTap: () async {
await MapsLauncher.launchQuery(
'${_userProvider.student?.school.city ?? ''} ${_userProvider.student?.school.name ?? ''}');
},
description: liveCard.nextLesson != null
? Text.rich(
TextSpan(
children: [
TextSpan(text: "first_lesson_1".i18n),
TextSpan(
text: liveCard.nextLesson!.subject.renamedTo ??
liveCard.nextLesson!.subject.name.capital(),
style: TextStyle(
fontWeight: FontWeight.w600,
color: Theme.of(context)
.colorScheme
.secondary
.withOpacity(.85),
fontStyle: liveCard.nextLesson!.subject.isRenamed &&
settingsProvider.renamedSubjectsItalics
? FontStyle.italic
: null),
),
TextSpan(text: "first_lesson_2".i18n),
TextSpan(
text: liveCard.nextLesson!.room.capital(),
style: TextStyle(
fontWeight: FontWeight.w600,
color: Theme.of(context)
.colorScheme
.secondary
.withOpacity(.85),
),
),
TextSpan(text: "first_lesson_3".i18n),
TextSpan(
text: DateFormat('H:mm')
.format(liveCard.nextLesson!.start),
style: TextStyle(
fontWeight: FontWeight.w600,
color: Theme.of(context)
.colorScheme
.secondary
.withOpacity(.85),
),
),
TextSpan(text: "first_lesson_4".i18n),
],
),
)
: null,
);
break;
case LiveCardState.duringLesson:
final elapsedTime = DateTime.now()
.difference(liveCard.currentLesson!.start)
.inSeconds
.toDouble() +
bellDelay.inSeconds;
final maxTime = liveCard.currentLesson!.end
.difference(liveCard.currentLesson!.start)
.inSeconds
.toDouble();
final showMinutes = maxTime - elapsedTime > 60;
child = LiveCardWidget(
key: const Key('livecard.duringLesson'),
leading: liveCard.currentLesson!.lessonIndex +
(RegExp(r'\d').hasMatch(liveCard.currentLesson!.lessonIndex)
? "."
: ""),
title: liveCard.currentLesson!.subject.renamedTo ??
liveCard.currentLesson!.subject.name.capital(),
titleItalic: liveCard.currentLesson!.subject.isRenamed &&
settingsProvider.renamedSubjectsEnabled &&
settingsProvider.renamedSubjectsItalics,
subtitle: liveCard.currentLesson!.room,
icon: SubjectIcon.resolveVariant(
subject: liveCard.currentLesson!.subject, context: context),
description: liveCard.currentLesson!.description != ""
? Text(liveCard.currentLesson!.description)
: null,
nextSubject: liveCard.nextLesson?.subject.renamedTo ??
liveCard.nextLesson?.subject.name.capital(),
nextSubjectItalic: liveCard.nextLesson?.subject.isRenamed == true &&
settingsProvider.renamedSubjectsEnabled &&
settingsProvider.renamedSubjectsItalics,
nextRoom: liveCard.nextLesson?.room,
progressMax: showMinutes ? maxTime / 60 : maxTime,
progressCurrent: showMinutes ? elapsedTime / 60 : elapsedTime,
progressAccuracy:
showMinutes ? ProgressAccuracy.minutes : ProgressAccuracy.seconds,
onProgressTap: () {
showDialog(
barrierColor: Colors.black,
context: context,
builder: (context) =>
HeadsUpCountdown(maxTime: maxTime, elapsedTime: elapsedTime),
);
},
);
break;
case LiveCardState.duringBreak:
final iconFloorMap = {
"to room": FeatherIcons.chevronsRight,
"up floor": FilcIcons.upstairs,
"down floor": FilcIcons.downstairs,
"ground floor": FilcIcons.downstairs,
};
final diff = liveCard.getFloorDifference();
final maxTime = liveCard.nextLesson!.start
.difference(liveCard.prevLesson!.end)
.inSeconds
.toDouble();
final elapsedTime = DateTime.now()
.difference(liveCard.prevLesson!.end)
.inSeconds
.toDouble() +
bellDelay.inSeconds.toDouble();
final showMinutes = maxTime - elapsedTime > 60;
child = LiveCardWidget(
key: const Key('livecard.duringBreak'),
title: "break".i18n,
icon: iconFloorMap[diff],
description: liveCard.nextLesson!.room != liveCard.prevLesson!.room
? Text("go $diff".i18n.fill([
diff != "to room"
? (liveCard.nextLesson!.getFloor() ?? 0)
: liveCard.nextLesson!.room
]))
: Text("stay".i18n),
nextSubject: liveCard.nextLesson?.subject.renamedTo ??
liveCard.nextLesson?.subject.name.capital(),
nextSubjectItalic: liveCard.nextLesson?.subject.isRenamed == true &&
settingsProvider.renamedSubjectsItalics,
nextRoom: diff != "to room" ? liveCard.nextLesson?.room : null,
progressMax: showMinutes ? maxTime / 60 : maxTime,
progressCurrent: showMinutes ? elapsedTime / 60 : elapsedTime,
progressAccuracy:
showMinutes ? ProgressAccuracy.minutes : ProgressAccuracy.seconds,
onProgressTap: () {
showDialog(
barrierColor: Colors.black,
context: context,
builder: (context) => HeadsUpCountdown(
maxTime: maxTime,
elapsedTime: elapsedTime,
),
);
},
);
break;
case LiveCardState.afternoon:
child = LiveCardWidget(
key: const Key('livecard.afternoon'),
title: DateFormat("EEEE", I18n.of(context).locale.toString())
.format(DateTime.now())
.capital(),
icon: FeatherIcons.coffee,
);
break;
case LiveCardState.night:
child = LiveCardWidget(
key: const Key('livecard.night'),
title: DateFormat("EEEE", I18n.of(context).locale.toString())
.format(DateTime.now())
.capital(),
icon: FeatherIcons.moon,
);
break;
default:
child = Container();
}
return PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
fillColor: Theme.of(context).scaffoldBackgroundColor,
child: child,
);
},
child: child,
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"next": "Next",
"remaining min": "%d mins".one("%d min"),
"remaining sec": "%d secs".one("%d sec"),
"break": "Break",
"go to room": "Go to room %s.",
"go ground floor": "Go to the ground floor.",
"go up floor": "Go upstairs, to floor %d.",
"go down floor": "Go downstaris, to floor %d.",
"stay": "Stay in this room.",
"first_lesson_1": "Your first lesson will be ",
"first_lesson_2": " in room ",
"first_lesson_3": ", at ",
"first_lesson_4": ".",
},
"hu_hu": {
"next": "Következő",
"remaining min": "%d perc".one("%d perc"),
"remaining sec": "%d másodperc".one("%d másodperc"),
"break": "Szünet",
"go to room": "Menj a(z) %s terembe.",
"go ground floor": "Menj a földszintre.",
"go up floor": "Menj fel a(z) %d. emeletre.",
"go down floor": "Menj le a(z) %d. emeletre.",
"stay": "Maradj ebben a teremben.",
"first_lesson_1": "Az első órád ",
"first_lesson_2": " lesz, a ",
"first_lesson_3": " teremben, ",
"first_lesson_4": "-kor.",
},
"de_de": {
"next": "Nächste",
"remaining min": "%d Minuten".one("%d Minute"),
"remaining sec": "%d Sekunden".one("%d Sekunden"),
"break": "Pause",
"go to room": "Geh in den Raum %s.",
"go ground floor": "Geh dir Treppe hinunter.",
"go up floor": "Geh in die %d. Stock hinauf.",
"go down floor": "Geh runter in den %d. Stock.",
"stay": "Im Zimmer bleiben.",
"first_lesson_1": "Ihre erste Stunde ist ",
"first_lesson_2": ", in Raum ",
"first_lesson_3": ", um ",
"first_lesson_4": " Uhr.",
},
};
String get i18n => localize(this, _t);
String fill(List<Object> params) => localizeFill(this, params);
String plural(int value) => localizePlural(value, this, _t);
String version(Object modifier) => localizeVersion(modifier, this, _t);
}

View File

@@ -0,0 +1,390 @@
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_mobile_ui/common/progress_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:provider/provider.dart';
import 'live_card.i18n.dart';
enum ProgressAccuracy { minutes, seconds }
class LiveCardWidget extends StatefulWidget {
const LiveCardWidget({
super.key,
this.isEvent = false,
this.leading,
this.title,
this.titleItalic = false,
this.subtitle,
this.icon,
this.description,
this.nextRoom,
this.nextSubject,
this.nextSubjectItalic = false,
this.progressCurrent,
this.progressMax,
this.progressAccuracy = ProgressAccuracy.minutes,
this.onProgressTap,
this.onTap,
});
final bool isEvent;
final String? leading;
final String? title;
final bool titleItalic;
final String? subtitle;
final IconData? icon;
final Widget? description;
final String? nextSubject;
final bool nextSubjectItalic;
final String? nextRoom;
final double? progressCurrent;
final double? progressMax;
final ProgressAccuracy? progressAccuracy;
final Function()? onProgressTap;
final Function()? onTap;
@override
State<LiveCardWidget> createState() => _LiveCardWidgetState();
}
class _LiveCardWidgetState extends State<LiveCardWidget> {
bool hold = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onLongPressDown: (_) => setState(() => hold = true),
onLongPressEnd: (_) => setState(() => hold = false),
onLongPressCancel: () => setState(() => hold = false),
onTap: widget.onTap,
child: AnimatedScale(
scale: hold ? 1.03 : 1.0,
curve: Curves.easeInOutBack,
duration: const Duration(milliseconds: 300),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 2.0),
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.circular(16.0),
boxShadow: [
if (Provider.of<SettingsProvider>(context, listen: false)
.shadowEffect)
BoxShadow(
offset: const Offset(0, 21),
blurRadius: 23.0,
color: Theme.of(context).shadowColor,
)
],
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6.0),
child: OverflowBox(
maxHeight: 96.0,
child: widget.isEvent
? Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
widget.title ?? 'Esemény',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 24.0,
color:
Theme.of(context).textTheme.bodyMedium?.color,
fontStyle:
widget.titleItalic ? FontStyle.italic : null,
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
widget.description ??
Text(
'Nincs leírás megadva.',
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 18.0,
color: Theme.of(context)
.textTheme
.bodyMedium
?.color,
),
),
SizedBox(
height: 15,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.secondary
.withOpacity(0.5),
borderRadius: const BorderRadius.all(
Radius.circular(10),
),
),
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(widget.icon),
),
),
),
],
),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.leading != null)
Padding(
padding: const EdgeInsets.only(
right: 12.0, top: 8.0),
child: Text(
widget.leading!,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.w600,
color: Theme.of(context)
.colorScheme
.secondary,
),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment:
MainAxisAlignment.spaceAround,
children: [
Row(
children: [
if (widget.title != null)
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: widget.title!,
style: TextStyle(
fontStyle: widget
.titleItalic
? FontStyle.italic
: null)),
if (widget.subtitle != null)
WidgetSpan(
child: Container(
margin: const EdgeInsets
.only(
left: 6.0,
bottom: 3.0),
padding:
const EdgeInsets
.symmetric(
horizontal: 4.0,
vertical: 2.0),
decoration:
BoxDecoration(
color: Theme.of(
context)
.colorScheme
.secondary
.withOpacity(.3),
borderRadius:
BorderRadius
.circular(
4.0),
),
child: Text(
widget.subtitle!,
style: TextStyle(
height: 1.2,
fontSize: 14.0,
fontWeight:
FontWeight.w600,
color: Theme.of(
context)
.colorScheme
.secondary,
),
),
),
),
],
),
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 22.0),
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
),
),
if (widget.title != null)
const SizedBox(width: 6.0),
if (widget.icon != null)
Padding(
padding: const EdgeInsets.symmetric(
vertical: 4.0),
child: Icon(
widget.icon,
size: 26.0,
color: AppColors.of(context)
.text
.withOpacity(.75),
),
),
],
),
if (widget.description != null)
DefaultTextStyle(
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(
fontWeight: FontWeight.w500,
fontSize: 16.0,
height: 1.0,
color: AppColors.of(context)
.text
.withOpacity(.75),
),
maxLines:
!(widget.nextSubject == null &&
widget.progressCurrent ==
null &&
widget.progressMax == null)
? 1
: 2,
softWrap: false,
overflow: TextOverflow.ellipsis,
child: widget.description!,
),
],
),
),
],
),
),
if (!(widget.nextSubject == null &&
widget.progressCurrent == null &&
widget.progressMax == null))
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
if (widget.nextSubject != null)
const Icon(FeatherIcons.arrowRight,
size: 12.0),
if (widget.nextSubject != null)
const SizedBox(width: 4.0),
if (widget.nextSubject != null)
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: widget.nextSubject!,
style: TextStyle(
fontStyle:
widget.nextSubjectItalic
? FontStyle.italic
: null)),
if (widget.nextRoom != null)
WidgetSpan(
child: Container(
margin: const EdgeInsets.only(
left: 4.0),
padding:
const EdgeInsets.symmetric(
horizontal: 3.0,
vertical: 1.5),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.secondary
.withOpacity(.25),
borderRadius:
BorderRadius.circular(
4.0),
),
child: Text(
widget.nextRoom!,
style: TextStyle(
height: 1.1,
fontSize: 12.0,
fontWeight: FontWeight.w600,
color: Theme.of(context)
.colorScheme
.secondary
.withOpacity(.9),
),
),
),
),
],
),
style: TextStyle(
color: AppColors.of(context)
.text
.withOpacity(.8),
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
),
),
if (widget.nextRoom == null &&
widget.nextSubject == null)
const Spacer(),
if (widget.progressCurrent != null &&
widget.progressMax != null)
GestureDetector(
onTap: widget.onProgressTap,
child: Container(
color: Colors.transparent,
child: Text(
"remaining ${widget.progressAccuracy == ProgressAccuracy.minutes ? 'min' : 'sec'}"
.plural((widget.progressMax! -
widget.progressCurrent!)
.round()),
maxLines: 1,
style: TextStyle(
fontWeight: FontWeight.w500,
color: AppColors.of(context)
.text
.withOpacity(.75),
),
),
),
)
],
),
),
if (widget.progressCurrent != null &&
widget.progressMax != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: ProgressBar(
value: widget.progressCurrent! /
widget.progressMax!),
)
],
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,438 @@
// MIT License
// Copyright (c) 2018 Norbert Kozsir
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
// ignore_for_file: invalid_use_of_protected_member
import 'dart:math';
import 'package:flutter/material.dart';
typedef ParticleBuilder = Particle Function(int index);
abstract class Particle {
void paint(Canvas canvas, Size size, double progress, int seed);
}
class FourRandomSlotParticle extends Particle {
final List<Particle> children;
final double relativeDistanceToMiddle;
FourRandomSlotParticle({required this.children, this.relativeDistanceToMiddle = 2.0});
@override
void paint(Canvas canvas, Size size, double progress, int seed) {
Random random = Random(seed);
int side = 0;
for (Particle particle in children) {
PositionedParticle(
position: sideToOffset(side, size, random) * relativeDistanceToMiddle,
child: particle,
).paint(canvas, size, progress, seed);
side++;
}
}
Offset sideToOffset(int side, Size size, Random random) {
if (side == 0) {
return Offset(-random.nextDouble() * (size.width / 2), -random.nextDouble() * (size.height / 2));
} else if (side == 1) {
return Offset(random.nextDouble() * (size.width / 2), -random.nextDouble() * (size.height / 2));
} else if (side == 2) {
return Offset(random.nextDouble() * (size.width / 2), random.nextDouble() * (size.height / 2));
} else if (side == 3) {
return Offset(-random.nextDouble() * (size.width / 2), random.nextDouble() * (size.height / 2));
} else {
throw Exception();
}
}
double randomOffset(Random random, int range) {
return range / 2 - random.nextInt(range);
}
}
class PoppingCircle extends Particle {
final Color color;
PoppingCircle({required this.color});
final double radius = 3.0;
@override
void paint(Canvas canvas, Size size, double progress, seed) {
if (progress < 0.5) {
canvas.drawCircle(
Offset.zero,
radius + (progress * 8),
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 5.0 - progress * 2);
} else {
CircleMirror(
numberOfParticles: 4,
child: AnimatedPositionedParticle(
begin: const Offset(0.0, 5.0),
end: const Offset(0.0, 15.0),
child: FadingRect(
color: color,
height: 7.0,
width: 2.0,
)),
initialRotation: pi / 4,
).paint(canvas, size, progress, seed);
}
}
}
class Firework extends Particle {
@override
void paint(Canvas canvas, Size size, double progress, int seed) {
FourRandomSlotParticle(children: [
IntervalParticle(
interval: const Interval(0.0, 0.5, curve: Curves.easeIn),
child: PoppingCircle(
color: Colors.deepOrangeAccent,
),
),
IntervalParticle(
interval: const Interval(0.2, 0.5, curve: Curves.easeIn),
child: PoppingCircle(
color: Colors.green,
),
),
IntervalParticle(
interval: const Interval(0.4, 0.8, curve: Curves.easeIn),
child: PoppingCircle(
color: Colors.indigo,
),
),
IntervalParticle(
interval: const Interval(0.5, 1.0, curve: Curves.easeIn),
child: PoppingCircle(
color: Colors.teal,
),
),
]).paint(canvas, size, progress, seed);
}
}
/// Mirrors a given particle around a circle.
///
/// When using the default constructor you specify one [Particle], this particle
/// is going to be used on its own, this implies that
/// all mirrored particles are identical (expect for the rotation around the circle)
class CircleMirror extends Particle {
final ParticleBuilder particleBuilder;
final double initialRotation;
final int numberOfParticles;
CircleMirror.builder({required this.particleBuilder, required this.initialRotation, required this.numberOfParticles});
CircleMirror({required Particle child, required this.initialRotation, required this.numberOfParticles}) : particleBuilder = ((index) => child);
@override
void paint(Canvas canvas, Size size, double progress, seed) {
canvas.save();
canvas.rotate(initialRotation);
for (int i = 0; i < numberOfParticles; i++) {
particleBuilder(i).paint(canvas, size, progress, seed);
canvas.rotate(pi / (numberOfParticles / 2));
}
canvas.restore();
}
}
/// Mirrors a given particle around a circle.
///
/// When using the default constructor you specify one [Particle], this particle
/// is going to be used on its own, this implies that
/// all mirrored particles are identical (expect for the rotation around the circle)
class RectangleMirror extends Particle {
final ParticleBuilder particleBuilder;
/// Position of the first particle on the rect
final double initialDistance;
final int numberOfParticles;
RectangleMirror.builder({required this.particleBuilder, required this.initialDistance, required this.numberOfParticles});
RectangleMirror({required Particle child, required this.initialDistance, required this.numberOfParticles})
: particleBuilder = ((index) => child);
@override
void paint(Canvas canvas, Size size, double progress, seed) {
canvas.save();
double totalLength = size.width * 2 + size.height * 2;
double distanceBetweenParticles = totalLength / numberOfParticles;
bool onHorizontalAxis = true;
int side = 0;
assert((distanceBetweenParticles * numberOfParticles).round() == totalLength.round());
canvas.translate(-size.width / 2, -size.height / 2);
double currentDistance = initialDistance;
for (int i = 0; i < numberOfParticles; i++) {
while (true) {
if (onHorizontalAxis ? currentDistance > size.width : currentDistance > size.height) {
currentDistance -= onHorizontalAxis ? size.width : size.height;
onHorizontalAxis = !onHorizontalAxis;
side = (++side) % 4;
} else {
if (side == 0) {
assert(onHorizontalAxis);
moveTo(canvas, size, 0, currentDistance, 0.0, () {
particleBuilder(i).paint(canvas, size, progress, seed);
});
} else if (side == 1) {
assert(!onHorizontalAxis);
moveTo(canvas, size, 1, size.width, currentDistance, () {
particleBuilder(i).paint(canvas, size, progress, seed);
});
} else if (side == 2) {
assert(onHorizontalAxis);
moveTo(canvas, size, 2, size.width - currentDistance, size.height, () {
particleBuilder(i).paint(canvas, size, progress, seed);
});
} else if (side == 3) {
assert(!onHorizontalAxis);
moveTo(canvas, size, 3, 0.0, size.height - currentDistance, () {
particleBuilder(i).paint(canvas, size, progress, seed);
});
}
break;
}
}
currentDistance += distanceBetweenParticles;
}
canvas.restore();
}
void moveTo(Canvas canvas, Size size, int side, double x, double y, VoidCallback painter) {
canvas.save();
canvas.translate(x, y);
canvas.rotate(-atan2(size.width / 2 - x, size.height / 2 - y));
painter();
canvas.restore();
}
}
/// Offsets a child by a given [Offset]
class PositionedParticle extends Particle {
PositionedParticle({required this.position, required this.child});
final Particle child;
final Offset position;
@override
void paint(Canvas canvas, Size size, double progress, seed) {
canvas.save();
canvas.translate(position.dx, position.dy);
child.paint(canvas, size, progress, seed);
canvas.restore();
}
}
/// Animates a childs position based on a Tween<Offset>
class AnimatedPositionedParticle extends Particle {
AnimatedPositionedParticle({required Offset begin, required Offset end, required this.child}) : offsetTween = Tween<Offset>(begin: begin, end: end);
final Particle child;
final Tween<Offset> offsetTween;
@override
void paint(Canvas canvas, Size size, double progress, seed) {
canvas.save();
canvas.translate(offsetTween.lerp(progress).dx, offsetTween.lerp(progress).dy);
child.paint(canvas, size, progress, seed);
canvas.restore();
}
}
/// Specifies an [Interval] for its child.
///
/// Instead of applying a curve the the input parameters of the paint method,
/// apply it with this Particle.
///
/// If you want you child to only animate from 0.0 - 0.5 (relative), specify an [Interval] with those values.
class IntervalParticle extends Particle {
final Interval interval;
final Particle child;
IntervalParticle({required this.child, required this.interval});
@override
void paint(Canvas canvas, Size size, double progress, seed) {
if (progress < interval.begin || progress > interval.end) return;
child.paint(canvas, size, interval.transform(progress), seed);
}
}
/// Does nothing else than holding a list of particles and painting them in that order
class CompositeParticle extends Particle {
final List<Particle> children;
CompositeParticle({required this.children});
@override
void paint(Canvas canvas, Size size, double progress, seed) {
for (Particle particle in children) {
particle.paint(canvas, size, progress, seed);
}
}
}
/// A particle which rotates the child.
///
/// Does not animate.
class RotationParticle extends Particle {
final Particle child;
final double rotation;
RotationParticle({required this.child, required this.rotation});
@override
void paint(Canvas canvas, Size size, double progress, int seed) {
canvas.save();
canvas.rotate(rotation);
child.paint(canvas, size, progress, seed);
canvas.restore();
}
}
/// A particle which rotates a child along a given [Tween]
class AnimatedRotationParticle extends Particle {
final Particle child;
final Tween<double> rotation;
AnimatedRotationParticle({required this.child, required double begin, required double end}) : rotation = Tween<double>(begin: begin, end: end);
@override
void paint(Canvas canvas, Size size, double progress, int seed) {
canvas.save();
canvas.rotate(rotation.lerp(progress));
child.paint(canvas, size, progress, seed);
canvas.restore();
}
}
/// Geometry
///
/// These are some basic geometric classes which also fade out as time goes on.
/// Each primitive should draw itself at the origin. If the orientation matters it should be directed to the top
/// (negative y)
///
/// A rectangle which also fades out over time.
class FadingRect extends Particle {
final Color color;
final double width;
final double height;
FadingRect({required this.color, required this.width, required this.height});
@override
void paint(Canvas canvas, Size size, double progress, seed) {
canvas.drawRect(Rect.fromLTWH(0.0, 0.0, width, height), Paint()..color = color.withOpacity(1 - progress));
}
}
/// A circle which fades out over time
class FadingCircle extends Particle {
final Color color;
final double radius;
FadingCircle({required this.color, required this.radius});
@override
void paint(Canvas canvas, Size size, double progress, seed) {
canvas.drawCircle(Offset.zero, radius, Paint()..color = color.withOpacity(1 - progress));
}
}
/// A triangle which also fades out over time
class FadingTriangle extends Particle {
/// This controls the shape of the triangle.
///
/// Value between 0 and 1
final double variation;
final Color color;
/// The size of the base side of the triangle.
final double baseSize;
/// This is the factor of how much bigger then length than the width is
final double heightToBaseFactor;
FadingTriangle({required this.variation, required this.color, required this.baseSize, required this.heightToBaseFactor});
@override
void paint(Canvas canvas, Size size, double progress, int seed) {
Path path = Path();
path.moveTo(0.0, 0.0);
path.lineTo(baseSize * variation, baseSize * heightToBaseFactor);
path.lineTo(baseSize, 0.0);
path.close();
canvas.drawPath(path, Paint()..color = color.withOpacity(1 - progress));
}
}
/// An ugly looking "snake"
///
/// See for yourself
class FadingSnake extends Particle {
final double width;
final double segmentLength;
final int segments;
final double curvyness;
final Color color;
FadingSnake({required this.width, required this.segmentLength, required this.segments, required this.curvyness, required this.color});
@override
void paint(Canvas canvas, Size size, double progress, int seed) {
canvas.save();
canvas.rotate(pi / 6);
Path path = Path();
for (int i = 0; i < segments; i++) {
path.quadraticBezierTo(curvyness * i, segmentLength * (i + 1), curvyness * (i + 1), segmentLength * (i + 1));
}
for (int i = segments - 1; i >= 0; i--) {
path.quadraticBezierTo(curvyness * (i + 1), segmentLength * i - curvyness, curvyness * i, segmentLength * i - curvyness);
}
path.close();
canvas.drawPath(path, Paint()..color = color);
canvas.restore();
}
}

View File

@@ -0,0 +1,240 @@
import 'dart:math';
import 'package:refilc/api/providers/update_provider.dart';
import 'package:refilc/ui/date_widget.dart';
import 'package:refilc_kreta_api/providers/message_provider.dart';
import 'package:refilc/api/providers/user_provider.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/message.dart';
import 'package:refilc_mobile_ui/common/bottom_sheet_menu/rounded_bottom_sheet.dart';
import 'package:refilc_mobile_ui/common/empty.dart';
import 'package:refilc_mobile_ui/common/filter_bar.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_button.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_image.dart';
import 'package:refilc/ui/filter/sort.dart';
// import 'package:refilc_mobile_ui/common/soon_alert/soon_alert.dart';
import 'package:refilc_mobile_ui/common/widgets/message/message_viewable.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:provider/provider.dart';
import 'messages_page.i18n.dart';
import 'send_message/send_message.dart';
class MessagesPage extends StatefulWidget {
const MessagesPage({super.key});
@override
MessagesPageState createState() => MessagesPageState();
}
class MessagesPageState extends State<MessagesPage>
with TickerProviderStateMixin {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
late UserProvider user;
late MessageProvider messageProvider;
late UpdateProvider updateProvider;
late String firstName;
late TabController tabController;
@override
void initState() {
super.initState();
tabController = TabController(length: 4, vsync: this);
}
@override
Widget build(BuildContext context) {
user = Provider.of<UserProvider>(context);
messageProvider = Provider.of<MessageProvider>(context);
updateProvider = Provider.of<UpdateProvider>(context);
List<String> nameParts = user.displayName?.split(" ") ?? ["?"];
firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0];
return Scaffold(
key: _scaffoldKey,
body: Padding(
padding: const EdgeInsets.only(top: 12.0),
child: NestedScrollView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics()),
headerSliverBuilder: (context, _) => [
SliverAppBar(
pinned: true,
floating: false,
snap: false,
centerTitle: false,
surfaceTintColor: Theme.of(context).scaffoldBackgroundColor,
actions: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0, vertical: 5.0),
child: IconButton(
splashRadius: 24.0,
onPressed: () async {
// Navigator.of(context, rootNavigator: true)
// .push(PageRouteBuilder(
// pageBuilder: (context, animation, secondaryAnimation) =>
// PremiumFSTimetable(
// controller: controller,
// ),
// ))
// .then((_) {
// SystemChrome.setPreferredOrientations(
// [DeviceOrientation.portraitUp]);
// setSystemChrome(context);
// });
// SoonAlert.show(context: context);
await showSendMessageSheet(context);
},
icon: Icon(
FeatherIcons.send,
color: AppColors.of(context).text,
),
),
),
// Profile Icon
Padding(
padding: const EdgeInsets.only(right: 24.0),
child: ProfileButton(
child: ProfileImage(
heroTag: "profile",
name: firstName,
backgroundColor: Theme.of(context)
.colorScheme
.primary, //ColorUtils.stringToColor(user.displayName ?? "?"),
badge: updateProvider.available,
role: user.role,
profilePictureString: user.picture,
),
),
),
],
automaticallyImplyLeading: false,
shadowColor: Theme.of(context).shadowColor,
title: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
"Messages".i18n,
style: TextStyle(
color: AppColors.of(context).text,
fontSize: 32.0,
fontWeight: FontWeight.bold),
),
),
bottom: FilterBar(
items: [
Tab(text: "Inbox".i18n),
Tab(text: "Sent".i18n),
Tab(text: "Trash".i18n),
Tab(text: "Draft".i18n),
],
controller: tabController,
disableFading: true,
),
),
],
body: TabBarView(
physics: const BouncingScrollPhysics(),
controller: tabController,
children: List.generate(
4, (index) => filterViewBuilder(context, index))),
),
),
);
}
List<DateWidget> getFilterWidgets(MessageType activeData) {
List<DateWidget> items = [];
switch (activeData) {
case MessageType.inbox:
for (var message in messageProvider.messages) {
if (message.type == MessageType.inbox) {
items.add(DateWidget(
date: message.date,
widget: MessageViewable(message),
));
}
}
break;
case MessageType.sent:
for (var message in messageProvider.messages) {
if (message.type == MessageType.sent) {
items.add(DateWidget(
date: message.date,
widget: MessageViewable(message),
));
}
}
break;
case MessageType.trash:
for (var message in messageProvider.messages) {
if (message.type == MessageType.trash) {
items.add(DateWidget(
date: message.date,
widget: MessageViewable(message),
));
}
}
break;
case MessageType.draft:
for (var message in messageProvider.messages) {
if (message.type == MessageType.draft) {
items.add(DateWidget(
date: message.date,
widget: MessageViewable(message),
));
}
}
break;
}
return items;
}
Widget filterViewBuilder(context, int activeData) {
List<Widget> filterWidgets = sortDateWidgets(context,
dateWidgets: getFilterWidgets(MessageType.values[activeData]),
hasShadow: true);
return Padding(
padding: const EdgeInsets.only(top: 12.0),
child: RefreshIndicator(
color: Theme.of(context).colorScheme.secondary,
onRefresh: () {
return Future.wait([
messageProvider.fetch(type: MessageType.inbox),
messageProvider.fetch(type: MessageType.sent),
messageProvider.fetch(type: MessageType.trash),
]);
},
child: ListView.builder(
padding: EdgeInsets.zero,
physics: const BouncingScrollPhysics(),
itemBuilder: (context, index) => filterWidgets.isNotEmpty
? Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24.0, vertical: 6.0),
child: filterWidgets[index],
)
: Empty(subtitle: "empty".i18n),
itemCount: max(filterWidgets.length, 1),
),
),
);
}
Future<void> showSendMessageSheet(BuildContext context) async {
await messageProvider.fetchAllRecipients();
_scaffoldKey.currentState?.showBottomSheet(
(context) => RoundedBottomSheet(
borderRadius: 14.0,
child: SendMessageSheet(messageProvider.recipients)),
backgroundColor: const Color(0x00000000),
elevation: 12.0,
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"Messages": "Messages",
"Inbox": "Inbox",
"Sent": "Sent",
"Trash": "Trash",
"Draft": "Draft",
"empty": "You have no messages.",
},
"hu_hu": {
"Messages": "Üzenetek",
"Inbox": "Beérkezett",
"Sent": "Elküldött",
"Trash": "Kuka",
"Draft": "Piszkozat",
"empty": "Nincsenek üzeneteid.",
},
"de_de": {
"Messages": "Nachrichten",
"Inbox": "Posteingang",
"Sent": "Gesendet",
"Trash": "Müll",
"Draft": "Entwurf",
"empty": "Sie haben keine Nachrichten.",
},
};
String get i18n => localize(this, _t);
String fill(List<Object> params) => localizeFill(this, params);
String plural(int value) => localizePlural(value, this, _t);
String version(Object modifier) => localizeVersion(modifier, this, _t);
}

View File

@@ -0,0 +1,221 @@
// ignore_for_file: use_build_context_synchronously
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/message.dart';
import 'package:refilc_kreta_api/providers/message_provider.dart';
import 'package:refilc_mobile_ui/common/custom_snack_bar.dart';
// import 'package:refilc_mobile_ui/common/custom_snack_bar.dart';
import 'package:refilc_mobile_ui/common/material_action_button.dart';
import 'package:refilc_mobile_ui/pages/messages/send_message/send_message.i18n.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
class SendMessageSheet extends StatefulWidget {
const SendMessageSheet(this.availableRecipients, {super.key});
final List<SendRecipient> availableRecipients;
@override
SendMessageSheetState createState() => SendMessageSheetState();
}
class SendMessageSheetState extends State<SendMessageSheet> {
late MessageProvider messageProvider;
final _subjectController = TextEditingController();
final _messageController = TextEditingController();
double newValue = 5.0;
double newWeight = 100.0;
List<SendRecipient> selectedRecipients = [];
@override
Widget build(BuildContext context) {
messageProvider = Provider.of<MessageProvider>(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(6.0),
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
"send_message".i18n,
style:
const TextStyle(fontSize: 20.0, fontWeight: FontWeight.w600),
),
),
// message recipients
Padding(
padding: const EdgeInsets.only(left: 8.0, right: 8.0),
child: DropdownButton2(
items: widget.availableRecipients
.map((item) => DropdownMenuItem<String>(
value: item.kretaId.toString(),
child: Text(
"${item.name ?? (item.id ?? 'Nincs név').toString()}${item.type.code != 'TANAR' ? " (${item.type.shortName})" : ''}",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppColors.of(context).text,
),
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (String? v) async {
int kretaId = int.parse(v ?? '0');
setState(() {
selectedRecipients.add(widget.availableRecipients
.firstWhere((e) => e.kretaId == kretaId));
widget.availableRecipients
.removeWhere((e) => e.kretaId == kretaId);
});
},
iconSize: 14,
iconEnabledColor: AppColors.of(context).text,
iconDisabledColor: AppColors.of(context).text,
underline: const SizedBox(),
itemHeight: 40,
itemPadding: const EdgeInsets.only(left: 14, right: 14),
buttonWidth: 50,
dropdownWidth: 300,
dropdownPadding: null,
buttonDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
dropdownDecoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
),
dropdownElevation: 8,
scrollbarRadius: const Radius.circular(40),
scrollbarThickness: 6,
scrollbarAlwaysShow: true,
offset: const Offset(-10, -10),
buttonSplashColor: Colors.transparent,
customButton: Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey, width: 2),
borderRadius: BorderRadius.circular(12.0),
),
padding:
const EdgeInsets.symmetric(vertical: 12.0, horizontal: 8.0),
child: Text(
selectedRecipients.isEmpty
? "select_recipient".i18n
: selectedRecipients
.map((e) =>
'${e.name ?? (e.id ?? 'Nincs név').toString()}, ')
.join(),
style: Theme.of(context).textTheme.titleSmall!.copyWith(
fontWeight: FontWeight.w700,
color: AppColors.of(context).text.withOpacity(0.75)),
overflow: TextOverflow.ellipsis,
maxLines: 2,
textAlign: TextAlign.center,
),
),
),
),
// Row(children: buildRecipientTiles()),
// message content
Column(children: [
Container(
// width: 180.0,
padding: const EdgeInsets.only(right: 12.0, left: 12.0),
child: Center(
child: TextField(
controller: _subjectController,
style: const TextStyle(
fontWeight: FontWeight.w600, fontSize: 18.0),
autocorrect: true,
textAlign: TextAlign.left,
keyboardType: TextInputType.text,
inputFormatters: [
LengthLimitingTextInputFormatter(100),
],
decoration: InputDecoration(
// border: InputBorder.none,
// enabledBorder: InputBorder.none,
// focusedBorder: InputBorder.none,
hintText: "message_subject".i18n,
suffixStyle: const TextStyle(fontSize: 16.0),
),
),
),
),
Container(
height: 200,
padding: const EdgeInsets.only(right: 12.0, left: 12.0),
child: TextField(
controller: _messageController,
style: const TextStyle(
fontWeight: FontWeight.w500, fontSize: 16.0),
autocorrect: true,
textAlign: TextAlign.left,
keyboardType: TextInputType.multiline,
maxLines: 10,
minLines: 1,
inputFormatters: [
LengthLimitingTextInputFormatter(500),
],
decoration: InputDecoration(
// border: InputBorder.none,
// enabledBorder: InputBorder.none,
// focusedBorder: InputBorder.none,
hintText: "message_text".i18n,
suffixStyle: const TextStyle(fontSize: 14.0),
),
),
),
]),
Container(
width: 120.0,
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: MaterialActionButton(
child: Text("send".i18n),
onPressed: () async {
if (_messageController.text.replaceAll(' ', '') == '') {
return;
}
String subjectText =
_subjectController.text.replaceAll(' ', '') != ''
? _subjectController.text
: 'Nincs tárgy';
var res = await messageProvider.sendMessage(
recipients: selectedRecipients,
subject: subjectText,
messageText: _messageController.text,
);
// do after send
if (res == 'send_permission_error') {
ScaffoldMessenger.of(context).showSnackBar(CustomSnackBar(
content: Text('cant_send'.i18n), context: context));
}
if (res == 'successfully_sent') {
ScaffoldMessenger.of(context).showSnackBar(CustomSnackBar(
content: Text('sent'.i18n), context: context));
}
Navigator.of(context).pop();
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,42 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"recipients": "Recipients",
"send_message": "Send Message",
"send": "Send",
"sent": "Message sent successfully.",
"message_subject": "Subject...",
"message_text": "Message text...",
"select_recipient": "Add Recipient",
"cant_send": "You can't send a message to one of the recipients!",
},
"hu_hu": {
"recipients": "Címzettek",
"send_message": "Üzenetküldés",
"send": "Küldés",
"sent": "Sikeres üzenetküldés.",
"message_subject": "Tárgy...",
"message_text": "Üzenet szövege...",
"select_recipient": "Címzett hozzáadása",
"cant_send": "Az egyik címzettnek nem küldhetsz üzenetet!",
},
"de_de": {
"recipients": "Empfänger",
"send_message": "Nachricht senden",
"send": "Versenden",
"sent": "Nachricht erfolgreich gesendet.",
"message_subject": "Betreff...",
"message_text": "Nachrichtentext...",
"select_recipient": "Empfänger hinzufügen",
"cant_send": "Neki nem küldhetsz üzenetet!",
},
};
String get i18n => localize(this, _t);
String fill(List<Object> params) => localizeFill(this, params);
String plural(int value) => localizePlural(value, this, _t);
String version(Object modifier) => localizeVersion(modifier, this, _t);
}

View File

@@ -0,0 +1,66 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
import 'package:refilc/utils/format.dart';
class DayTitle extends StatefulWidget {
const DayTitle({super.key, required this.dayTitle, required this.controller});
final String Function(int) dayTitle;
final TabController controller;
@override
State<DayTitle> createState() => _DayTitleState();
}
class _DayTitleState extends State<DayTitle> {
@override
void initState() {
super.initState();
widget.controller.addListener(listener);
}
void listener() {
if (mounted) setState(() {});
}
@override
void dispose() {
widget.controller.removeListener(listener);
super.dispose();
}
@override
Widget build(BuildContext context) {
double width = MediaQuery.of(context).size.width;
return AnimatedBuilder(
animation: widget.controller.animation!,
builder: (context, _) {
double value = widget.controller.animation!.value;
return Transform.translate(
offset: Offset(-value * width / 1.5, 0),
child: Row(
children: List.generate(
widget.controller.length,
(index) {
double opacity = (value - index + 1).clamp(0, 1);
return SizedBox(
width: MediaQuery.of(context).size.width / 1.5,
child: Text(
widget.dayTitle(index).capital(),
style: TextStyle(
color:
AppColors.of(context).text.withOpacity(opacity),
fontSize: 32.0,
fontWeight: FontWeight.bold),
),
);
},
),
),
);
});
}
}

View File

@@ -0,0 +1,262 @@
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:refilc/helpers/subject.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/controllers/timetable_controller.dart';
import 'package:refilc_kreta_api/models/lesson.dart';
import 'package:refilc_mobile_ui/common/empty.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:refilc/utils/format.dart';
import 'dart:math' as math;
import 'package:intl/intl.dart';
import 'package:i18n_extension/i18n_widget.dart';
import 'package:provider/provider.dart';
class FSTimetable extends StatefulWidget {
const FSTimetable({super.key, required this.controller});
final TimetableController controller;
@override
State<FSTimetable> createState() => _FSTimetableState();
}
class _FSTimetableState extends State<FSTimetable> {
late SettingsProvider settings;
@override
void initState() {
super.initState();
SystemChrome.setPreferredOrientations([
// it's different on the fruit platform
Platform.isIOS
? DeviceOrientation.landscapeRight
: DeviceOrientation.landscapeLeft,
]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
systemNavigationBarColor: Colors.transparent,
));
}
@override
Widget build(BuildContext context) {
settings = Provider.of<SettingsProvider>(context);
if (widget.controller.days == null || widget.controller.days!.isEmpty) {
return const Center(child: Empty());
}
final days = widget.controller.days!;
final everyLesson = days.expand((x) => x).toList();
everyLesson.sort((a, b) => a.start.compareTo(b.start));
final int maxLessonCount = days.fold(
0,
(a, b) => math.max(
a, b.where((l) => l.subject.id != "" || l.isEmpty).length));
const prefixw = 45;
const padding = prefixw + 6 * 2;
final colw = (MediaQuery.of(context).size.width - padding) / days.length;
return Scaffold(
// appBar: AppBar(
// surfaceTintColor: Theme.of(context).scaffoldBackgroundColor,
// leading: BackButton(color: AppColors.of(context).text),
// shadowColor: Colors.transparent,
// ),
body: ListView.builder(
physics: const BouncingScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 24.0),
itemCount: maxLessonCount + 1,
itemBuilder: (context, index) {
List<Widget> columns = [];
for (int dayIndex = -1; dayIndex < days.length; dayIndex++) {
if (dayIndex == -1) {
if (index >= 1) {
columns.add(SizedBox(
width: prefixw.toDouble(),
height: 40.0,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
"${index - 1}.",
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.secondary,
),
),
),
));
} else {
columns.add(
SizedBox(
width: prefixw.toDouble(),
child: Padding(
padding: const EdgeInsets.only(top: 4.0),
child: GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: const Icon(FeatherIcons.x),
),
),
),
);
}
continue;
}
final lessons = days[dayIndex]
.where((l) => l.subject.id != "" || l.isEmpty)
.toList();
if (lessons.isEmpty) continue;
int lsnIndx = int.tryParse(lessons.first.lessonIndex) ?? 1;
final dayOffset = lsnIndx == 0 ? 1 : lsnIndx;
if (index == 0 && dayIndex >= 0) {
columns.add(
SizedBox(
width: colw,
height: 40.0,
child: Text(
DateFormat("EEEE", I18n.of(context).locale.languageCode)
.format(lessons.first.date)
.capital(),
style: const TextStyle(
fontSize: 24.0,
fontWeight: FontWeight.bold,
),
),
),
);
continue;
}
final lessonIndex = index - dayOffset;
Lesson? lsn = lessons.firstWhereOrNull(
(e) => e.lessonIndex == (index - 1).toString());
if (lessonIndex < 0 ||
lessonIndex > lessons.length ||
(index == 1 && lsnIndx != 0) ||
(lsnIndx != 0 && lessonIndex - 1 == -1) ||
lsn == null) {
columns.add(SizedBox(width: colw));
continue;
}
if (lsn.isEmpty) {
columns.add(
SizedBox(
width: colw,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
FeatherIcons.slash,
size: 18.0,
color: AppColors.of(context).text.withOpacity(.3),
),
const SizedBox(width: 8.0),
Text(
"Lyukas óra",
style: TextStyle(
color: AppColors.of(context).text.withOpacity(.3),
),
),
],
),
),
);
continue;
}
// print(lessons.map((e) => e.name + '---' + e.lessonIndex));
// print(lessons[lessonIndex].name +
// '---' +
// index.toString() +
// '_' +
// lessonIndex.toString());
// print(lessonIndex);
// if (lsnIndx != 1 && index - 2 >= 0) {
// lsn = lessons[index - 2];
// }
// if (index > 1 && lsnIndx > 0) {
// lsn = lessons[lessonIndex - 1];
// }
columns.add(
SizedBox(
width: colw,
child: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
SubjectIcon.resolveVariant(
context: context,
subject: lsn.subject,
),
size: 18.0,
color: AppColors.of(context).text.withOpacity(.7),
),
const SizedBox(width: 8.0),
Expanded(
child: Text(
lsn.subject.renamedTo ??
lsn.subject.name.capital(),
maxLines: 1,
style: TextStyle(
fontStyle: lsn.subject.isRenamed &&
settings.renamedSubjectsItalics
? FontStyle.italic
: null,
),
overflow: TextOverflow.clip,
softWrap: false,
),
),
const SizedBox(width: 15),
],
),
Padding(
padding: const EdgeInsets.only(left: 26.0),
child: Text(
lsn.room,
style: TextStyle(
color: AppColors.of(context).text.withOpacity(.5),
overflow: TextOverflow.ellipsis,
),
),
),
],
),
),
),
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: columns,
);
},
),
);
}
}

View File

@@ -0,0 +1,794 @@
import 'dart:math';
import 'package:animations/animations.dart';
import 'package:refilc/api/providers/update_provider.dart';
import 'package:refilc/models/settings.dart';
// TODO: gulag calendar sync
// import 'package:refilc/providers/third_party_provider.dart';
import 'package:refilc/utils/format.dart';
import 'package:refilc_kreta_api/client/client.dart';
import 'package:refilc_kreta_api/models/week.dart';
import 'package:refilc_kreta_api/providers/timetable_provider.dart';
import 'package:refilc/api/providers/user_provider.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/lesson.dart';
import 'package:refilc_mobile_ui/common/dot.dart';
import 'package:refilc_mobile_ui/common/empty.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_button.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_image.dart';
import 'package:refilc_mobile_ui/common/system_chrome.dart';
import 'package:refilc_mobile_ui/common/widgets/lesson/lesson_view.dart';
import 'package:refilc_kreta_api/controllers/timetable_controller.dart';
import 'package:refilc_mobile_ui/common/widgets/lesson/lesson_viewable.dart';
import 'package:refilc_mobile_ui/pages/timetable/day_title.dart';
import 'package:refilc_mobile_ui/pages/timetable/fs_timetable.dart';
import 'package:refilc_mobile_ui/screens/navigation/navigation_route_handler.dart';
import 'package:refilc_mobile_ui/screens/navigation/navigation_screen.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart';
import 'package:i18n_extension/i18n_widget.dart';
import 'timetable_page.i18n.dart';
// todo: "fix" overflow (priority: -1)
class TimetablePage extends StatefulWidget {
const TimetablePage({super.key, this.initialDay, this.initialWeek});
final DateTime? initialDay;
final Week? initialWeek;
static void jump(BuildContext context,
{Week? week, DateTime? day, Lesson? lesson}) {
// Go to timetable page with arguments
NavigationScreen.of(context)
?.customRoute(navigationPageRoute((context) => TimetablePage(
initialDay: lesson?.date ?? day,
initialWeek: lesson?.date != null
? Week.fromDate(lesson!.date)
: day != null
? Week.fromDate(day)
: week,
)));
NavigationScreen.of(context)?.setPage("timetable");
// Show initial Lesson
if (lesson != null) LessonView.show(lesson, context: context);
}
@override
TimetablePageState createState() => TimetablePageState();
}
class TimetablePageState extends State<TimetablePage>
with TickerProviderStateMixin, WidgetsBindingObserver {
late UserProvider user;
late TimetableProvider timetableProvider;
late UpdateProvider updateProvider;
late SettingsProvider settingsProvider;
late String firstName;
late TimetableController _controller;
late TabController _tabController;
late Widget empty;
int _getDayIndex(DateTime date) {
int index = 0;
if (_controller.days == null || (_controller.days?.isEmpty ?? true)) {
return index;
}
// find the first day with upcoming lessons
index = _controller.days!.indexWhere((day) => day.last.end.isAfter(date));
if (index == -1) index = 0; // fallback
return index;
}
// Update timetable on user change
Future<void> _userListener() async {
await Provider.of<KretaClient>(context, listen: false).refreshLogin();
if (mounted) _controller.jump(_controller.currentWeek, context: context);
}
// When the app comes to foreground, refresh the timetable
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
if (mounted) _controller.jump(_controller.currentWeek, context: context);
}
}
@override
void initState() {
super.initState();
// Initalize controllers
_controller = TimetableController();
_tabController = TabController(length: 0, vsync: this, initialIndex: 0);
empty = Empty(subtitle: "empty".i18n);
bool initial = true;
// Only update the TabController on week changes
_controller.addListener(() {
if (_controller.days == null) return;
setState(() {
_tabController = TabController(
length: _controller.days!.length,
vsync: this,
initialIndex:
min(_tabController.index, max(_controller.days!.length - 1, 0)),
);
if (initial ||
_controller.previousWeekId != _controller.currentWeekId) {
_tabController
.animateTo(_getDayIndex(widget.initialDay ?? DateTime.now()));
}
initial = false;
// Empty is updated once every week change
empty = Empty(subtitle: "empty".i18n);
});
});
if (mounted) {
if (widget.initialWeek != null) {
_controller.jump(widget.initialWeek!, context: context, initial: true);
} else {
_controller.jump(_controller.currentWeek,
context: context, initial: true, skip: true);
}
}
// Listen for user changes
user = Provider.of<UserProvider>(context, listen: false);
user.addListener(_userListener);
// Register listening for app state changes to refresh the timetable
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
_tabController.dispose();
_controller.dispose();
user.removeListener(_userListener);
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
String dayTitle(int index) {
// Sometimes when changing weeks really fast,
// controller.days might be null or won't include index
try {
return DateFormat("EEEE", I18n.of(context).locale.languageCode)
.format(_controller.days![index].first.date);
} catch (e) {
return "timetable".i18n;
}
}
@override
Widget build(BuildContext context) {
user = Provider.of<UserProvider>(context);
timetableProvider = Provider.of<TimetableProvider>(context);
updateProvider = Provider.of<UpdateProvider>(context);
settingsProvider = Provider.of<SettingsProvider>(context);
// First name
List<String> nameParts = user.displayName?.split(" ") ?? ["?"];
firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0];
return Scaffold(
body: Padding(
padding: const EdgeInsets.only(top: 9.0),
child: RefreshIndicator(
onRefresh: () => mounted
? _controller.jump(_controller.currentWeek,
context: context, loader: false)
: Future.value(null),
color: Theme.of(context).colorScheme.secondary,
edgeOffset: 132.0,
child: NestedScrollView(
physics: const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics()),
headerSliverBuilder: (context, _) => [
SliverAppBar(
centerTitle: false,
pinned: true,
floating: false,
snap: false,
surfaceTintColor: Theme.of(context).scaffoldBackgroundColor,
actions: [
Padding(
padding: const EdgeInsets.all(8.0),
child: IconButton(
splashRadius: 24.0,
// tested timetable sync
// onPressed: () async {
// ThirdPartyProvider tpp =
// Provider.of<ThirdPartyProvider>(context,
// listen: false);
// await tpp.pushTimetable(context, _controller);
// },
onPressed: () {
// If timetable empty, show empty
if (_tabController.length == 0) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text("empty_timetable".i18n),
duration: const Duration(seconds: 2),
));
return;
}
Navigator.of(context, rootNavigator: true)
.push(PageRouteBuilder(
pageBuilder:
(context, animation, secondaryAnimation) =>
FSTimetable(
controller: _controller,
),
))
.then((_) {
SystemChrome.setPreferredOrientations(
[DeviceOrientation.portraitUp]);
setSystemChrome(context);
});
},
icon: Icon(FeatherIcons.trello,
color: AppColors.of(context).text),
),
),
// Profile Icon
Padding(
padding: const EdgeInsets.only(right: 24.0),
child: ProfileButton(
child: ProfileImage(
heroTag: "profile",
name: firstName,
backgroundColor: Theme.of(context)
.colorScheme
.primary, //ColorUtils.stringToColor(user.displayName ?? "?"),
badge: updateProvider.available,
role: user.role,
profilePictureString: user.picture,
),
),
),
],
automaticallyImplyLeading: false,
// Current day text
title: PageTransitionSwitcher(
reverse:
_controller.currentWeekId < _controller.previousWeekId,
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return SharedAxisTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.horizontal,
fillColor: Theme.of(context).scaffoldBackgroundColor,
child: child,
);
},
layoutBuilder: (List<Widget> entries) {
return Stack(
children: entries,
);
},
child: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Row(
children: [
() {
final show = _controller.days == null ||
(_controller.loadType != LoadType.offline &&
_controller.loadType != LoadType.online);
const duration = Duration(milliseconds: 150);
return AnimatedOpacity(
opacity: show ? 1.0 : 0.0,
duration: duration,
curve: Curves.easeInOut,
child: AnimatedContainer(
duration: duration,
width: show ? 24.0 : 0.0,
curve: Curves.easeInOut,
child: const Padding(
padding: EdgeInsets.only(right: 12.0),
child: CupertinoActivityIndicator(),
),
),
);
}(),
() {
if ((_controller.days?.length ?? 0) > 0) {
return DayTitle(
controller: _tabController, dayTitle: dayTitle);
} else {
return Text(
"timetable".i18n,
style: TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.bold,
color: AppColors.of(context).text,
),
);
}
}(),
],
),
),
),
shadowColor: Theme.of(context).shadowColor,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(50.0),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Previous week
IconButton(
onPressed: _controller.currentWeekId == 0
? null
: () => setState(() {
_controller.previous(context);
}),
splashRadius: 24.0,
icon: const Icon(FeatherIcons.chevronLeft),
color: Theme.of(context).colorScheme.secondary),
// Week selector
InkWell(
borderRadius: BorderRadius.circular(6.0),
onTap: () => setState(() {
_controller.current();
if (mounted) {
_controller.jump(
_controller.currentWeek,
context: context,
loader: _controller.currentWeekId !=
_controller.previousWeekId,
);
}
_tabController
.animateTo(_getDayIndex(DateTime.now()));
}),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
"${DateFormat("${_controller.currentWeek.start.year != DateTime.now().year ? "yyyy. " : ""}MMM d", I18n.of(context).locale.languageCode).format(_controller.currentWeek.start)}${DateFormat("${_controller.currentWeek.start.year != DateTime.now().year ? " - yyyy. MMM " : (_controller.currentWeek.start.month == _controller.currentWeek.end.month ? '-' : ' - MMM ')}d", I18n.of(context).locale.languageCode).format(_controller.currentWeek.end)} ${_controller.currentWeekId + 1}. ${"week".i18n}",
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14.0,
),
),
),
),
// Next week
IconButton(
onPressed: _controller.currentWeekId == 51
? null
: () => setState(() {
_controller.next(context);
}),
splashRadius: 24.0,
icon: const Icon(FeatherIcons.chevronRight),
color: Theme.of(context).colorScheme.secondary),
],
),
),
),
),
],
body: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return FadeThroughTransition(
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
fillColor: Theme.of(context).scaffoldBackgroundColor,
child: child,
);
},
child: _controller.days != null
? Column(
key: Key(_controller.currentWeek.toString()),
children: [
// Week view
_tabController.length > 0
? Expanded(
child: TabBarView(
physics: const BouncingScrollPhysics(),
controller: _tabController,
// days
children: List.generate(
_controller.days!.length,
(tab) => RefreshIndicator(
onRefresh: () => mounted
? _controller.jump(
_controller.currentWeek,
context: context,
loader: false)
: Future.value(null),
color: Theme.of(context)
.colorScheme
.secondary,
child: ListView.builder(
padding: EdgeInsets.zero,
physics: const BouncingScrollPhysics(),
itemCount:
_controller.days![tab].length,
itemBuilder: (context, index) {
if (_controller.days == null) {
return Container();
}
// Header
// if (index == 0) {
// return const Padding(
// padding: EdgeInsets.only(
// top: 8.0,
// left: 24.0,
// right: 24.0),
// child: PanelHeader(
// padding: EdgeInsets.only(
// top: 12.0)),
// );
// }
// Footer
// if (index ==
// _controller.days![tab].length +
// 1) {
// return const Padding(
// padding: EdgeInsets.only(
// bottom: 8.0,
// left: 24.0,
// right: 24.0),
// child: PanelFooter(
// padding: EdgeInsets.only(
// top: 12.0)),
// );
// }
// Body
int len =
_controller.days![tab].length;
final Lesson lesson =
_controller.days![tab][index];
final Lesson? before =
len + index > len
? _controller.days![tab]
[index - 1]
: null;
final bool swapDescDay = _controller
.days![tab]
.map(
(l) => l.swapDesc ? 1 : 0)
.reduce((a, b) => a + b) >=
_controller.days![tab].length *
.5;
return Column(
children: [
if (before != null &&
(before.end.hour != 0 &&
lesson.start.hour != 0) &&
settingsProvider.showBreaks)
Padding(
padding: EdgeInsets.only(
top: index == 0
? 0.0
: 12.0,
left: 24,
right: 24),
child: Container(
padding:
const EdgeInsets.all(
10.0),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.25),
),
borderRadius:
BorderRadius.circular(
16.0),
),
child: Row(
mainAxisAlignment:
MainAxisAlignment
.spaceBetween,
children: [
Row(
children: [
Container(
padding:
const EdgeInsets
.symmetric(
horizontal:
8.0,
vertical:
2.5),
decoration:
BoxDecoration(
borderRadius:
BorderRadius
.circular(
50.0),
color: AppColors.of(
context)
.text
.withOpacity(
0.90),
),
child: Text(
'break'.i18n,
style:
TextStyle(
color: Theme.of(
context)
.scaffoldBackgroundColor,
fontSize:
12.5,
fontWeight:
FontWeight
.w500,
height: 1.1,
),
),
),
const SizedBox(
width: 10.0,
),
Text(
'${DateFormat("H:mm", I18n.of(context).locale.languageCode).format(before.end)} - ${DateFormat("H:mm", I18n.of(context).locale.languageCode).format(lesson.start)}',
// '${before.end.hour}:${before.end.minute} - ${lesson.start.hour}:${lesson.start.minute}',
style:
const TextStyle(
fontSize: 12.5,
fontWeight:
FontWeight
.w500,
),
),
],
),
if (DateTime.now()
.isBefore(lesson
.start) &&
DateTime.now()
.isAfter(
before.end))
Dot(
color: Theme.of(
context)
.colorScheme
.secondary
.withOpacity(
.5),
size: 10.0,
)
],
),
),
),
Padding(
padding: EdgeInsets.only(
top:
index == 0 ? 5.0 : 12.0,
left: 24,
right: 24,
bottom: index + 1 == len
? 20.0
: 0),
child: Container(
padding: const EdgeInsets
.symmetric(
horizontal: 6.0),
decoration: BoxDecoration(
boxShadow: [
if (Provider.of<
SettingsProvider>(
context,
listen: false)
.shadowEffect)
BoxShadow(
offset: const Offset(
0, 21),
blurRadius: 23.0,
color:
Theme.of(context)
.shadowColor,
)
],
color: Theme.of(context)
.colorScheme
.background,
borderRadius:
BorderRadius.only(
topLeft: index == 0
? const Radius
.circular(16.0)
: const Radius
.circular(16.0),
topRight: index == 0
? const Radius
.circular(16.0)
: const Radius
.circular(16.0),
bottomLeft: index + 1 ==
len
? const Radius
.circular(16.0)
: const Radius
.circular(16.0),
bottomRight: index + 1 ==
len
? const Radius
.circular(16.0)
: const Radius
.circular(16.0),
),
),
child: LessonViewable(
lesson,
swapDesc: swapDescDay,
),
),
),
],
);
// return Padding(
// padding: const EdgeInsets.symmetric(
// horizontal: 24.0),
// child: PanelBody(
// padding:
// const EdgeInsets.symmetric(
// horizontal: 10.0),
// child: LessonViewable(
// lesson,
// swapDesc: swapDescDay,
// ),
// ),
// );
},
),
),
),
),
)
// Empty week
: Expanded(
child: Center(child: empty),
),
// Day selector
TabBar(
dividerColor: Colors.transparent,
controller: _tabController,
// Label
labelPadding: EdgeInsets.zero,
labelColor:
AppColors.of(context).text.withOpacity(0.9),
unselectedLabelColor: Theme.of(context)
.colorScheme
.secondary
.withOpacity(0.25)
.withAlpha(100),
// Indicator
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding:
const EdgeInsets.symmetric(horizontal: 10.0),
indicator: BoxDecoration(
color: Colors.transparent,
border: Border.all(
color: AppColors.of(context)
.text
.withOpacity(0.90)),
// color: Theme.of(context)
// .colorScheme
// .secondary
// .withOpacity(0.25),
borderRadius: BorderRadius.circular(16.0),
),
overlayColor: MaterialStateProperty.all(
const Color(0x00000000)),
// Tabs
padding: const EdgeInsets.symmetric(
vertical: 6.0, horizontal: 24.0),
tabs: List.generate(_tabController.length, (index) {
String label = DateFormat("EEEE",
I18n.of(context).locale.languageCode)
.format(_controller.days![index].first.date)
.capital();
return Tab(
height: 56.0,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_sameDate(
_controller.days![index].first.date,
DateTime.now()))
Padding(
padding: const EdgeInsets.only(top: 0.0),
child: Dot(
size: 4.0,
color: Theme.of(context)
.colorScheme
.secondary
.withOpacity(0.25)
.withAlpha(100)),
),
Text(
label.substring(0, min(2, label.length)),
style: const TextStyle(
fontSize: 22.0,
fontWeight: FontWeight.w600,
height: 1.1,
),
),
SizedBox(
height: _sameDate(
_controller.days![index].first.date,
DateTime.now())
? 0.0
: 3.0,
),
Text(
_controller.days![index].first.date.day
.toString(),
style: TextStyle(
height: 1.0,
fontWeight: FontWeight.w500,
fontSize: 17.0,
color: Theme.of(context)
.colorScheme
.secondary
.withOpacity(0.25)
.withAlpha(100),
),
),
],
),
);
}),
),
],
)
: const SizedBox(),
),
),
),
),
);
}
}
// difference.inDays is not reliable
bool _sameDate(DateTime a, DateTime b) =>
(a.year == b.year && a.month == b.month && a.day == b.day);

View File

@@ -0,0 +1,36 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"timetable": "Timetable",
"empty": "No school this week!",
"week": "Week",
"error": "Failed to fetch timetable!",
"empty_timetable": "Timetable is empty!",
"break": "Break",
},
"hu_hu": {
"timetable": "Órarend",
"empty": "Ezen a héten nincs iskola.",
"week": "hét",
"error": "Nem sikerült lekérni az órarendet!",
"empty_timetable": "Az órarend üres!",
"break": "Szünet",
},
"de_de": {
"timetable": "Zeitplan",
"empty": "Keine Schule diese Woche.",
"week": "Woche",
"error": "Der Fahrplan konnte nicht abgerufen werden!",
"empty_timetable": "Der Zeitplan ist blank!",
"break": "Pause",
},
};
String get i18n => localize(this, _t);
String fill(List<Object> params) => localizeFill(this, params);
String plural(int value) => localizePlural(value, this, _t);
String version(Object modifier) => localizeVersion(modifier, this, _t);
}