Add files via upload

This commit is contained in:
ReinerRego
2023-05-26 21:48:51 +02:00
committed by GitHub
parent 59b5acc79b
commit 258a6ab8d3
64 changed files with 6779 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
import 'package:filcnaplo/theme/colors/colors.dart';
import 'package:flutter/material.dart';
class FilterBar extends StatelessWidget implements PreferredSizeWidget {
const FilterBar({
Key? key,
required this.items,
required this.controller,
this.onTap,
this.padding = const EdgeInsets.symmetric(horizontal: 24.0),
this.disableFading = false,
this.scrollable = true,
}) : assert(items.length == controller.length),
super(key: key);
final List<Widget> items;
final TabController controller;
final EdgeInsetsGeometry padding;
final Function(int)? onTap;
@override
final Size preferredSize = const Size.fromHeight(42.0);
final bool disableFading;
final bool scrollable;
@override
Widget build(BuildContext context) {
final tabbar = TabBar(
controller: controller,
isScrollable: scrollable,
physics: const BouncingScrollPhysics(),
// Label
labelStyle: Theme.of(context).textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w600,
fontSize: 15.0,
),
labelPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
labelColor: Theme.of(context).colorScheme.secondary,
unselectedLabelColor: AppColors.of(context).text.withOpacity(0.65),
// Indicator
indicatorPadding: const EdgeInsets.symmetric(vertical: 8),
indicator: BoxDecoration(
color: Theme.of(context).colorScheme.secondary.withOpacity(0.25),
borderRadius: BorderRadius.circular(45.0),
),
overlayColor: MaterialStateProperty.all(const Color(0x00000000)),
// Tabs
padding: EdgeInsets.zero,
tabs: items,
onTap: onTap,
);
return Container(
width: MediaQuery.of(context).size.width,
height: 48.0,
padding: padding,
child: disableFading
? tabbar
: AnimatedBuilder(
animation: controller.animation!,
builder: (ctx, child) {
// avoid fading over selected tab
return ShaderMask(
shaderCallback: (Rect bounds) {
final Color bg = Theme.of(context).scaffoldBackgroundColor;
final double index = controller.animation!.value;
return LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [
index < 0.2 ? Colors.transparent : bg,
Colors.transparent,
Colors.transparent,
index > controller.length - 1.2 ? Colors.transparent : bg
], stops: const [
0,
0.1,
0.9,
1
]).createShader(bounds);
},
blendMode: BlendMode.dstOut,
child: child);
},
child: tabbar,
),
);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:filcnaplo/theme/colors/colors.dart';
import 'package:flutter/material.dart';
class PanelButton extends StatelessWidget {
const PanelButton({
Key? key,
this.onPressed,
this.padding = const EdgeInsets.symmetric(horizontal: 14.0),
this.leading,
this.title,
this.trailing,
this.trailingDivider = false,
}) : super(key: key);
final void Function()? onPressed;
final EdgeInsetsGeometry padding;
final Widget? leading;
final Widget? title;
final Widget? trailing;
final bool trailingDivider;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: RawMaterialButton(
onPressed: onPressed,
padding: padding,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
child: ListTile(
leading: leading != null
? Theme(
data: Theme.of(context).copyWith(iconTheme: IconThemeData(color: Theme.of(context).colorScheme.secondary)),
child: leading!,
)
: null,
trailing: trailingDivider
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
margin: const EdgeInsets.only(right: 6.0),
width: 2.0,
height: 32.0,
decoration: BoxDecoration(
color: AppColors.of(context).text.withOpacity(.15),
borderRadius: BorderRadius.circular(45.0),
),
),
if (trailing != null) trailing!,
],
)
: trailing,
title: title != null
? DefaultTextStyle(
style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w600, fontSize: 14.0),
child: title!,
)
: null,
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
),
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:filcnaplo/models/user.dart';
import 'package:filcnaplo/utils/color.dart';
import 'package:flutter/material.dart';
import 'package:filcnaplo/theme/colors/colors.dart';
class ProfileImage extends StatelessWidget {
const ProfileImage({
Key? key,
this.name,
this.radius = 20.0,
this.role = Role.student,
this.backgroundColor,
}) : super(key: key);
final String? name;
final double radius;
final Role? role;
final Color? backgroundColor;
@override
Widget build(BuildContext context) {
Color color = ColorUtils.foregroundColor(backgroundColor ?? Theme.of(context).scaffoldBackgroundColor);
Color roleColor;
if (Theme.of(context).brightness == Brightness.light) {
roleColor = const Color(0xFF444444);
} else {
roleColor = const Color(0xFF555555);
}
return Stack(
alignment: Alignment.center,
children: [
Material(
clipBehavior: Clip.hardEdge,
shape: const CircleBorder(),
color: backgroundColor ?? AppColors.of(context).text.withOpacity(.15),
child: InkWell(
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: radius * 2,
width: radius * 2,
decoration: const BoxDecoration(
shape: BoxShape.circle,
),
child: name != null && (name?.trim().length ?? 0) > 0
? Center(
child: Text(
(name?.trim().length ?? 0) > 0 ? (name ?? "?").trim()[0] : "?",
style: TextStyle(
color: color,
fontWeight: FontWeight.w600,
fontSize: 18.0 * (radius / 20.0),
),
),
)
: Container(),
),
),
),
// Role indicator
if (role == Role.parent)
SizedBox(
height: radius * 2,
width: radius * 2,
child: Container(
alignment: Alignment.bottomRight,
child: Icon(Icons.shield, color: roleColor, size: radius / 1.3),
),
),
],
);
}
}

View File

@@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
import 'package:filcnaplo_kreta_api/models/grade.dart';
import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart';
class GradeViewable extends StatelessWidget {
const GradeViewable(this.grade, {Key? key}) : super(key: key);
final Grade grade;
@override
Widget build(BuildContext context) {
return GradeTile(grade);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:filcnaplo_kreta_api/models/lesson.dart';
import 'package:filcnaplo/ui/widgets/lesson/lesson_tile.dart';
import 'package:flutter/material.dart';
class LessonViewable extends StatelessWidget {
const LessonViewable(this.lesson, {Key? key, this.swapDesc = false}) : super(key: key);
final Lesson lesson;
final bool swapDesc;
@override
Widget build(BuildContext context) {
final tile = LessonTile(lesson, swapDesc: swapDesc);
if (lesson.subject.id == '' || tile.lesson.isEmpty) return tile;
return tile;
}
}

View File

@@ -0,0 +1,362 @@
import 'dart:math';
import 'package:animations/animations.dart';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:filcnaplo/api/providers/update_provider.dart';
import 'package:filcnaplo/ui/date_widget.dart';
import 'package:filcnaplo_kreta_api/models/absence.dart';
import 'package:filcnaplo_kreta_api/models/lesson.dart';
import 'package:filcnaplo_kreta_api/models/subject.dart';
import 'package:filcnaplo_kreta_api/models/week.dart';
import 'package:filcnaplo_kreta_api/providers/absence_provider.dart';
import 'package:filcnaplo_kreta_api/providers/note_provider.dart';
import 'package:filcnaplo/api/providers/user_provider.dart';
import 'package:filcnaplo/theme/colors/colors.dart';
import 'package:filcnaplo_kreta_api/providers/timetable_provider.dart';
import 'package:filcnaplo_mobile_ui/common/action_button.dart';
import 'package:filcnaplo_mobile_ui/common/empty.dart';
import 'package:filcnaplo_mobile_ui/common/filter_bar.dart';
import 'package:filcnaplo_mobile_ui/common/panel/panel.dart';
import 'package:filcnaplo_mobile_ui/common/widgets/absence/absence_subject_tile.dart';
import 'package:filcnaplo_mobile_ui/common/widgets/absence/absence_viewable.dart';
import 'package:filcnaplo_mobile_ui/common/widgets/statistics_tile.dart';
import 'package:filcnaplo_mobile_ui/common/widgets/miss_tile.dart';
import 'package:filcnaplo_mobile_ui/pages/absences/absence_subject_view.dart';
import 'package:filcnaplo/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 {
Subject subject;
List<Absence> absences;
double percentage;
SubjectAbsence({required this.subject, this.absences = const [], this.percentage = 0.0});
}
class AbsencesPage extends StatefulWidget {
const AbsencesPage({Key? key}) : super(key: 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<Subject, 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,
);
}
}
});
}
void buildSubjectAbsences() {
Map<Subject, 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,
automaticallyImplyLeading: false,
shadowColor: Theme.of(context).shadowColor,
surfaceTintColor: Theme.of(context).scaffoldBackgroundColor,
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 = [];
if (activeData > 0) {
filterWidgets = sortDateWidgets(
context,
dateWidgets: getFilterWidgets(AbsenceFilter.values[activeData]),
padding: EdgeInsets.zero,
hasShadow: true,
);
} else {
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(
child: child,
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
fillColor: Theme.of(context).colorScheme.background,
);
},
child: Column(
children: getFilterWidgets(AbsenceFilter.values[activeData]).map((e) => e.widget).cast<Widget>().toList(),
),
),
),
)
];
}
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,57 @@
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.",
},
"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.",
},
"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.",
},
};
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,268 @@
import 'dart:math';
import 'package:animations/animations.dart';
import 'package:filcnaplo/utils/format.dart';
import 'package:filcnaplo_kreta_api/providers/grade_provider.dart';
import 'package:filcnaplo/helpers/average_helper.dart';
import 'package:filcnaplo/helpers/subject.dart';
import 'package:filcnaplo_kreta_api/models/grade.dart';
import 'package:filcnaplo_kreta_api/models/subject.dart';
import 'package:filcnaplo_mobile_ui/common/average_display.dart';
import 'package:filcnaplo_mobile_ui/common/bottom_sheet_menu/rounded_bottom_sheet.dart';
import 'package:filcnaplo_mobile_ui/common/panel/panel.dart';
import 'package:filcnaplo_mobile_ui/common/trend_display.dart';
import 'package:filcnaplo_mobile_ui/common/widgets/cretification/certification_tile.dart';
import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart';
import 'package:filcnaplo_mobile_ui/common/widgets/grade/grade_viewable.dart';
import 'package:filcnaplo_mobile_ui/common/hero_scrollview.dart';
import 'package:filcnaplo_mobile_ui/pages/grades/calculator/grade_calculator.dart';
import 'package:filcnaplo_mobile_ui/pages/grades/calculator/grade_calculator_provider.dart';
import 'package:filcnaplo_desktop_ui/pages/grades/grades_count.dart';
import 'package:filcnaplo_mobile_ui/pages/grades/graph.dart';
import 'package:filcnaplo_mobile_ui/pages/grades/subject_grades_container.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:filcnaplo_premium/ui/mobile/goalplanner/new_goal.dart';
class GradeSubjectView extends StatefulWidget {
const GradeSubjectView(this.subject, {Key? key, this.groupAverage = 0.0}) : super(key: key);
final Subject 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 double average;
late Widget gradeGraph;
bool gradeCalcMode = false;
List<Grade> getSubjectGrades(Subject 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));
}
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,
child: child,
fillColor: Colors.transparent,
);
},
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
Widget build(BuildContext context) {
gradeProvider = Provider.of<GradeProvider>(context);
calculatorProvider = Provider.of<GradeCalculatorProvider>(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: 12.0, right: 12.0),
child: Row(
children: [
Expanded(child: GradeGraph(subjectGrades, dayThreshold: 5, classAvg: widget.groupAverage)),
Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: GradesCount(grades: subjectGrades),
),
],
),
),
),
);
if (!gradeCalcMode) {
buildTiles(subjectGrades);
} else {
List<Grade> ghostGrades = calculatorProvider.ghosts.where((e) => e.subject == widget.subject).toList();
buildTiles(ghostGrades);
}
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,
children: [
FloatingActionButton.small(
child: const Icon(FeatherIcons.plus),
onPressed: () {
gradeCalc(context);
},
),
// FloatingActionButton.small(
// child: const Icon(FeatherIcons.flag, size: 20.0),
// onPressed: () {
// Navigator.of(context).push(CupertinoPageRoute(builder: (context) => PremiumGoalplannerNewGoalScreen(subject: widget.subject)));
// },
// ),
],
),
),
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: 12.0),
],
icon: SubjectIcon.resolveVariant(subject: widget.subject, context: context),
scrollController: _scrollController,
title: widget.subject.renamedTo ?? widget.subject.name.capital(),
italic: 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(75, duration: const Duration(milliseconds: 500), curve: Curves.ease);
calculatorProvider.clear();
calculatorProvider.addAllGrades(gradeProvider.grades);
_sheetController = _scaffoldKey.currentState?.showBottomSheet(
(context) => RoundedBottomSheet(child: GradeCalculator(widget.subject), borderRadius: 14.0),
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,25 @@
import 'package:flutter/material.dart';
import 'package:filcnaplo_kreta_api/models/grade.dart';
import 'package:filcnaplo_desktop_ui/pages/grades/grades_count_item.dart';
import 'package:collection/collection.dart';
class GradesCount extends StatelessWidget {
const GradesCount({Key? key, required this.grades}) : super(key: key);
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 Container(
width: 75,
padding: const EdgeInsets.only(bottom: 6.0, top: 6.0, left: 12.0, right: 0.0),
margin: const EdgeInsets.symmetric(horizontal: 12.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: gradesCount.mapIndexed((index, e) => GradesCountItem(count: e, value: index + 1)).toList(),
),
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart';
import 'package:filcnaplo_kreta_api/models/grade.dart';
import 'package:flutter/material.dart';
class GradesCountItem extends StatelessWidget {
const GradesCountItem({Key? key, required this.count, required this.value}) : super(key: key);
final int count;
final int value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
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: 5.0),
GradeValueWidget(GradeValue(value, "Value", "Value", 100), size: 19.0, fill: true, shadow: false),
],
),
);
}
}

View File

@@ -0,0 +1,263 @@
import 'dart:math';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:filcnaplo/api/providers/update_provider.dart';
import 'package:filcnaplo_kreta_api/providers/grade_provider.dart';
import 'package:filcnaplo/api/providers/user_provider.dart';
import 'package:filcnaplo/theme/colors/colors.dart';
import 'package:filcnaplo_kreta_api/models/grade.dart';
import 'package:filcnaplo_kreta_api/models/subject.dart';
import 'package:filcnaplo_kreta_api/models/group_average.dart';
import 'package:filcnaplo_mobile_ui/common/average_display.dart';
import 'package:filcnaplo_mobile_ui/common/empty.dart';
import 'package:filcnaplo_mobile_ui/common/panel/panel.dart';
import 'package:filcnaplo_mobile_ui/common/widgets/statistics_tile.dart';
import 'package:filcnaplo_mobile_ui/common/widgets/grade/grade_subject_tile.dart';
import 'package:filcnaplo_mobile_ui/common/trend_display.dart';
import 'package:filcnaplo_mobile_ui/pages/grades/fail_warning.dart';
import 'package:filcnaplo_desktop_ui/pages/grades/grades_count.dart';
import 'package:filcnaplo_mobile_ui/pages/grades/graph.dart';
import 'package:filcnaplo_desktop_ui/pages/grades/grade_subject_view.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:filcnaplo/helpers/average_helper.dart';
import 'package:filcnaplo_premium/ui/mobile/grades/average_selector.dart';
import 'grades_page.i18n.dart';
class GradesPage extends StatefulWidget {
const GradesPage({Key? key}) : super(key: key);
@override
_GradesPageState createState() => _GradesPageState();
}
class _GradesPageState extends State<GradesPage> {
late UserProvider user;
late GradeProvider gradeProvider;
late UpdateProvider updateProvider;
late String firstName;
late Widget yearlyGraph;
List<Widget> subjectTiles = [];
int avgDropValue = 0;
List<Grade> getSubjectGrades(Subject subject, {int days = 0}) => gradeProvider.grades
.where(
(e) => e.subject == subject && e.type == GradeType.midYear && (days == 0 || e.date.isBefore(DateTime.now().subtract(Duration(days: days)))))
.toList();
void generateTiles() {
List<Subject> subjects = gradeProvider.grades.map((e) => e.subject).toSet().toList()..sort((a, b) => a.name.compareTo(b.name));
List<Widget> tiles = [];
Map<Subject, double> subjectAvgs = {};
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;
return GradeSubjectTile(
subject,
averageBefore: averageBefore,
average: avg,
groupAverage: avgDropValue == 0 ? groupAverage : 0.0,
onTap: () {
GradeSubjectView(subject, groupAverage: groupAverage).push(context, root: true);
},
);
}));
if (tiles.isNotEmpty) {
tiles.insert(0, yearlyGraph);
tiles.insert(1, FailWarning(subjectAvgs: subjectAvgs));
tiles.insert(2, PanelTitle(title: Text(avgDropValue == 0 ? "Subjects".i18n : "Subjects_changes".i18n)));
tiles.insert(3, const PanelHeader(padding: EdgeInsets.only(top: 12.0)));
tiles.add(const PanelFooter(padding: EdgeInsets.only(bottom: 12.0)));
tiles.add(const Padding(padding: EdgeInsets.only(bottom: 24.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) {
tiles.add(Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: StatisticsTile(
fill: true,
title: AutoSizeText(
"subjectavg".i18n,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
value: subjectAvg,
),
),
const SizedBox(width: 24.0),
Expanded(
child: StatisticsTile(
outline: true,
title: AutoSizeText(
"classavg".i18n,
textAlign: TextAlign.center,
maxLines: 2,
wrapWords: false,
overflow: TextOverflow.ellipsis,
),
value: classAvg,
),
),
],
));
}
// 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);
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(gradeProvider.grades.where((e) => e.type == GradeType.midYear).toList());
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 = gradeProvider.grades
.where((e) => e.type == GradeType.midYear && (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: [
PremiumAverageSelector(
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: Row(
children: [
Expanded(child: GradeGraph(graphGrades, dayThreshold: 2, classAvg: totalClassAvg)),
Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: GradesCount(grades: graphGrades),
),
],
),
),
),
);
generateTiles();
return Scaffold(
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,
automaticallyImplyLeading: false,
surfaceTintColor: Theme.of(context).scaffoldBackgroundColor,
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(
child: subjectTiles[index],
padding: const EdgeInsets.symmetric(horizontal: 8.0),
));
} else {
return Padding(padding: panelPadding, child: subjectTiles[index]);
}
} else {
return Container();
}
},
),
),
),
),
);
}
}

View File

@@ -0,0 +1,60 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"Grades": "Grades",
"Ghost Grades": "Grades",
"Subjects": "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": "Faliure warning",
"fail_warning_description": "You are failing %d subject(s)",
},
"hu_hu": {
"Grades": "Jegyek",
"Ghost Grades": "Szellem jegyek",
"Subjects": "Tantárgyak",
"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",
},
"de_de": {
"Grades": "Noten",
"Ghost Grades": "Geist Noten",
"Subjects": "Fächer",
"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",
},
};
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,165 @@
import 'package:filcnaplo/api/providers/user_provider.dart';
import 'package:filcnaplo/models/settings.dart';
import 'package:filcnaplo/ui/date_widget.dart';
import 'package:filcnaplo_desktop_ui/common/filter_bar.dart';
import 'package:flutter/material.dart';
import 'package:animated_list_plus/animated_list_plus.dart';
import 'package:provider/provider.dart';
import 'package:filcnaplo/ui/filter/widgets.dart';
import 'package:filcnaplo/ui/filter/sort.dart';
import 'home_page.i18n.dart';
class HomePage extends StatefulWidget {
const HomePage({Key? key}) : super(key: key);
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> with SingleTickerProviderStateMixin {
late UserProvider user;
late SettingsProvider settings;
late TabController _tabController;
late PageController _pageController;
late String greeting;
late String firstName;
late List<String> listOrder;
static const pageCount = 4;
@override
void initState() {
super.initState();
_tabController = TabController(length: pageCount, vsync: this);
_pageController = PageController();
listOrder = List.generate(pageCount, (index) => "$index");
user = Provider.of<UserProvider>(context, listen: false);
DateTime now = DateTime.now();
if (now.isBefore(DateTime(now.year, DateTime.august, 31)) && now.isAfter(DateTime(now.year, DateTime.june, 14))) {
greeting = "goodrest";
} else if (now.month == user.student?.birth.month && now.day == user.student?.birth.day) {
greeting = "happybirthday";
} 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 (now.hour >= 18) {
greeting = "goodevening";
} else if (now.hour >= 10) {
greeting = "goodafternoon";
} else if (now.hour >= 4) {
greeting = "goodmorning";
} else {
greeting = "goodevening";
}
}
@override
Widget build(BuildContext context) {
user = Provider.of<UserProvider>(context);
settings = Provider.of<SettingsProvider>(context);
List<String> nameParts = user.name?.split(" ") ?? ["?"];
if (!settings.presentationMode) {
firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0];
} else {
firstName = "Béla";
}
return SafeArea(
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Greeting
Padding(
padding: const EdgeInsets.only(left: 32.0, top: 24.0, bottom: 12.0),
child: Text(
greeting.i18n.fill([firstName]),
overflow: TextOverflow.fade,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 24.0,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
),
),
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: FilterBar(
items: [
Tab(text: "All".i18n),
Tab(text: "Grades".i18n),
Tab(text: "Messages".i18n),
Tab(text: "Absences".i18n),
],
controller: _tabController,
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;
});
},
disableFading: true,
),
),
// Data filters
Expanded(
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
? ImplicitlyAnimatedList<Widget>(
items: sortDateWidgets(context, dateWidgets: dateWidgets.data!),
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: 4,
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()),
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,63 @@
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!",
"empty": "Nothing to see here.",
"All": "All",
"Grades": "Grades",
"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!",
"empty": "Nincs itt semmi látnivaló.",
"All": "Összes",
"Grades": "Jegyek",
"Messages": "Üzenetek",
"Absences": "Hiányok",
"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!",
"empty": "Hier gibt es nichts zu sehen.",
"All": "Alles",
"Grades": "Noten",
"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,164 @@
import 'dart:math';
import 'package:filcnaplo/api/providers/update_provider.dart';
import 'package:filcnaplo/ui/date_widget.dart';
import 'package:filcnaplo_kreta_api/providers/message_provider.dart';
import 'package:filcnaplo/api/providers/user_provider.dart';
import 'package:filcnaplo/theme/colors/colors.dart';
import 'package:filcnaplo_kreta_api/models/message.dart';
import 'package:filcnaplo_mobile_ui/common/empty.dart';
import 'package:filcnaplo_mobile_ui/common/filter_bar.dart';
import 'package:filcnaplo/ui/filter/sort.dart';
import 'package:filcnaplo_mobile_ui/common/widgets/message/message_viewable.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'messages_page.i18n.dart';
class MessagesPage extends StatefulWidget {
const MessagesPage({Key? key}) : super(key: key);
@override
_MessagesPageState createState() => _MessagesPageState();
}
class _MessagesPageState extends State<MessagesPage> with TickerProviderStateMixin {
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(
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,
automaticallyImplyLeading: false,
shadowColor: Theme.of(context).shadowColor,
surfaceTintColor: Theme.of(context).scaffoldBackgroundColor,
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),
),
),
);
}
}

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,333 @@
import 'dart:math';
import 'package:animations/animations.dart';
import 'package:filcnaplo/api/providers/update_provider.dart';
import 'package:filcnaplo/theme/colors/colors.dart';
import 'package:filcnaplo_kreta_api/client/client.dart';
import 'package:filcnaplo_kreta_api/models/week.dart';
import 'package:filcnaplo_kreta_api/providers/timetable_provider.dart';
import 'package:filcnaplo/api/providers/user_provider.dart';
import 'package:filcnaplo_kreta_api/models/lesson.dart';
import 'package:filcnaplo_mobile_ui/common/empty.dart';
import 'package:filcnaplo_mobile_ui/common/panel/panel.dart';
import 'package:filcnaplo_kreta_api/controllers/timetable_controller.dart';
import 'package:filcnaplo_desktop_ui/common/widgets/lesson/lesson_viewable.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:provider/provider.dart';
import 'package:filcnaplo/utils/format.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({Key? key, this.initialDay, this.initialWeek}) : super(key: key);
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 {
late UserProvider user;
late TimetableProvider timetableProvider;
late UpdateProvider updateProvider;
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);
}
@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);
}
@override
void dispose() {
_tabController.dispose();
_controller.dispose();
user.removeListener(_userListener);
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);
// First name
List<String> nameParts = user.name?.split(" ") ?? ["?"];
firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0];
return Scaffold(
body: Padding(
padding: const EdgeInsets.only(top: 18.0),
child: Column(
children: [
Expanded(
child: PageTransitionSwitcher(
transitionBuilder: (
Widget child,
Animation<double> primaryAnimation,
Animation<double> secondaryAnimation,
) {
return FadeThroughTransition(
child: child,
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
fillColor: Theme.of(context).scaffoldBackgroundColor,
);
},
child: _controller.days != null
?
// Week view
_tabController.length > 0
? CupertinoScrollbar(
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: _controller.days!.length,
itemBuilder: (context, tab) => SizedBox(
width: 400,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Day Title
Padding(
padding: const EdgeInsets.only(left: 24.0, right: 28.0, top: 18.0, bottom: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
dayTitle(tab).capital(),
style: const TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.w600,
),
),
Text(
"${_controller.days![tab].first.date.day}".padLeft(2, '0') + ".",
style: TextStyle(
color: AppColors.of(context).text.withOpacity(.5),
fontWeight: FontWeight.w500,
),
),
],
),
),
// Lessons
Expanded(
child: ListView.builder(
padding: EdgeInsets.zero,
physics: const BouncingScrollPhysics(),
itemCount: _controller.days![tab].length + 2,
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
final Lesson lesson = _controller.days![tab][index - 1];
final bool swapDescDay = _controller.days![tab].map((l) => l.swapDesc ? 1 : 0).reduce((a, b) => a + b) >=
_controller.days![tab].length * .5;
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),
)
: Center(
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.secondary,
),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.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);
}
}),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
"${_controller.currentWeekId + 1}. " +
"week".i18n +
" (" +
// Week start
DateFormat((_controller.currentWeek.start.year != DateTime.now().year ? "yy. " : "") + "MMM d.",
I18n.of(context).locale.languageCode)
.format(_controller.currentWeek.start) +
" - " +
// Week end
DateFormat((_controller.currentWeek.start.year != DateTime.now().year ? "yy. " : "") + "MMM d.",
I18n.of(context).locale.languageCode)
.format(_controller.currentWeek.end) +
")",
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),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,30 @@
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!",
},
"hu_hu": {
"timetable": "Órarend",
"empty": "Ezen a héten nincs iskola.",
"week": "Hét",
"error": "Nem sikerült lekérni az órarendet!",
},
"de_de": {
"timetable": "Zeitplan",
"empty": "Keine Schule diese Woche.",
"week": "Woche",
"error": "Der Fahrplan konnte nicht abgerufen 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,17 @@
import 'package:animations/animations.dart';
import 'package:flutter/material.dart';
Route loginRoute(Widget widget) {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => widget,
transitionDuration: const Duration(milliseconds: 650),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeThroughTransition(
fillColor: Colors.transparent,
animation: animation,
secondaryAnimation: secondaryAnimation,
child: child,
);
},
);
}

View File

@@ -0,0 +1,319 @@
import 'dart:io';
import 'dart:ui';
import 'package:elegant_notification/elegant_notification.dart';
import 'package:elegant_notification/resources/arrays.dart';
import 'package:filcnaplo/api/client.dart';
import 'package:filcnaplo/api/login.dart';
import 'package:filcnaplo/theme/colors/colors.dart';
import 'package:filcnaplo_mobile_ui/screens/login/login_button.dart';
import 'package:filcnaplo_mobile_ui/screens/login/login_input.dart';
import 'package:filcnaplo_mobile_ui/screens/login/school_input/school_input.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_acrylic/flutter_acrylic.dart';
import 'login_screen.i18n.dart';
class LoginScreen extends StatefulWidget {
const LoginScreen({Key? key, this.back = false}) : super(key: key);
final bool back;
@override
_LoginScreenState createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final usernameController = TextEditingController();
final passwordController = TextEditingController();
final schoolController = SchoolInputController();
final _scrollController = ScrollController();
LoginState _loginState = LoginState.normal;
bool showBack = false;
double topInset = 12.0;
@override
void initState() {
super.initState();
showBack = widget.back;
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Colors.white,
systemNavigationBarIconBrightness: Brightness.dark,
));
FilcAPI.getSchools().then((schools) {
if (schools != null) {
schoolController.update(() {
schoolController.schools = schools;
});
} else {
ElegantNotification.error(
background: Colors.white,
description: Text(
"schools_error".i18n,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.red),
),
onActionPressed: () {},
onCloseButtonPressed: () {},
onDismiss: () {},
onProgressFinished: () {},
displayCloseButton: false,
showProgressIndicator: false,
autoDismiss: true,
animation: AnimationType.fromTop,
).show(context);
}
});
if (Platform.isMacOS) Window.getTitlebarHeight().then((value) => setState(() => topInset = value));
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.of(context).filc,
body: SafeArea(
child: Stack(
children: [
Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// App logo
Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: ClipRect(
child: Padding(
padding: const EdgeInsets.only(left: 12.0, right: 12.0, bottom: 32.0),
child: SizedBox(
width: 82.0,
height: 82.0,
// Png shadow *hack*
child: Stack(
children: [
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Opacity(child: Image.asset("assets/icons/ic_splash.png", color: Colors.black), opacity: 0.3),
),
BackdropFilter(
filter: ImageFilter.blur(sigmaX: 6.0, sigmaY: 6.0),
child: Image.asset("assets/icons/ic_splash.png"),
)
],
),
),
),
),
),
// Inputs
SizedBox(
width: 400.0,
child: AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Username
Padding(
padding: const EdgeInsets.only(bottom: 6.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
"username".i18n,
maxLines: 1,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 14.0,
),
),
),
Expanded(
child: Text(
"usernameHint".i18n,
maxLines: 1,
textAlign: TextAlign.right,
style: const TextStyle(
color: Colors.white54,
fontWeight: FontWeight.w500,
fontSize: 12.0,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: LoginInput(
style: LoginInputStyle.username,
controller: usernameController,
),
),
// Password
Padding(
padding: const EdgeInsets.only(bottom: 6.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
"password".i18n,
maxLines: 1,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 14.0,
),
),
),
Expanded(
child: Text(
"passwordHint".i18n,
maxLines: 1,
textAlign: TextAlign.right,
style: const TextStyle(
color: Colors.white54,
fontWeight: FontWeight.w500,
fontSize: 12.0,
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: LoginInput(
style: LoginInputStyle.password,
controller: passwordController,
),
),
// School
Padding(
padding: const EdgeInsets.only(bottom: 6.0),
child: Text(
"school".i18n,
maxLines: 1,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 14.0,
),
),
),
SchoolInput(
scroll: _scrollController,
controller: schoolController,
),
],
),
),
),
// Log in button
SizedBox(
width: 400.0,
child: Padding(
padding: const EdgeInsets.only(top: 42.0),
child: Visibility(
child: LoginButton(
child: Text("login".i18n,
maxLines: 1,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15.0,
)),
onPressed: () => _loginApi(context: context),
),
visible: _loginState != LoginState.inProgress,
replacement: const Padding(
padding: EdgeInsets.symmetric(vertical: 6.0),
child: Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
),
),
),
),
),
if (_loginState == LoginState.missingFields || _loginState == LoginState.invalidGrant || _loginState == LoginState.failed)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
["missing_fields", "invalid_grant", "error"][_loginState.index].i18n,
style: const TextStyle(color: Colors.red, fontWeight: FontWeight.w500),
),
),
],
),
),
],
),
if (showBack)
Container(
alignment: Alignment.topLeft,
padding: EdgeInsets.only(left: 16.0, top: topInset),
child: const ClipOval(
child: Material(
type: MaterialType.transparency,
child: BackButton(color: Colors.white),
),
),
),
],
),
),
);
}
void _loginApi({required BuildContext context}) {
String username = usernameController.text;
String password = passwordController.text;
if (username == "" || password == "" || schoolController.selectedSchool == null) {
return setState(() => _loginState = LoginState.missingFields);
}
setState(() => _loginState = LoginState.inProgress);
loginApi(
username: username,
password: password,
instituteCode: schoolController.selectedSchool!.instituteCode,
context: context,
onLogin: (user) {
ElegantNotification.success(
background: Colors.white,
description: Text(
"welcome".i18n.fill([user.name]),
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.black),
),
onActionPressed: () {},
onCloseButtonPressed: () {},
onDismiss: () {},
onProgressFinished: () {},
displayCloseButton: false,
showProgressIndicator: false,
autoDismiss: true,
animation: AnimationType.fromTop,
).show(context);
},
onSuccess: () {
Navigator.of(context).pushNamedAndRemoveUntil("login_to_navigation", (_) => false);
}).then((res) => setState(() => _loginState = res));
}
}

View File

@@ -0,0 +1,51 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"username": "Username",
"usernameHint": "Student ID number",
"password": "Password",
"passwordHint": "Date of birth",
"school": "School",
"login": "Log in",
"welcome": "Welcome, %s!",
"missing_fields": "Missing Fields!",
"invalid_grant": "Invalid Username/Password!",
"error": "Failed to log in.",
"schools_error": "Failed to get schools."
},
"hu_hu": {
"username": "Felhasználónév",
"usernameHint": "Oktatási azonosító",
"password": "Jelszó",
"passwordHint": "Születési dátum",
"school": "Iskola",
"login": "Belépés",
"welcome": "Üdv, %s!",
"missing_fields": "Hiányzó adatok!",
"invalid_grant": "Helytelen Felhasználónév/Jelszó!",
"error": "Sikertelen bejelentkezés.",
"schools_error": "Nem sikerült lekérni az iskolákat."
},
"de_de": {
"username": "Benutzername",
"usernameHint": "Ausbildung ID",
"password": "Passwort",
"passwordHint": "Geburtsdatum",
"school": "Schule",
"login": "Einloggen",
"welcome": "Wilkommen, %s!",
"missing_fields": "Fehlende Felder!",
"invalid_grant": "Ungültiger Benutzername/Passwort!",
"error": "Anmeldung fehlgeschlagen.",
"schools_error": "Keine Schulen gefunden."
},
};
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,25 @@
class NavigationRoute {
late String _name;
late int _index;
final List<String> _internalPageMap = [
"home",
"grades",
"timetable",
"messages",
"absences",
];
String get name => _name;
int get index => _index;
set name(String n) {
_name = n;
_index = _internalPageMap.indexOf(n);
}
set index(int i) {
_index = i;
_name = _internalPageMap.elementAt(i);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:animations/animations.dart';
import 'package:filcnaplo_desktop_ui/pages/absences/absences_page.dart';
import 'package:filcnaplo_desktop_ui/pages/grades/grades_page.dart';
import 'package:filcnaplo_desktop_ui/pages/home/home_page.dart';
import 'package:filcnaplo_desktop_ui/pages/messages/messages_page.dart';
import 'package:filcnaplo_desktop_ui/pages/timetable/timetable_page.dart';
import 'package:flutter/material.dart';
Route navigationRouteHandler(RouteSettings settings) {
switch (settings.name) {
case "grades":
return navigationPageRoute((context) => const GradesPage());
case "timetable":
return navigationPageRoute((context) => const TimetablePage());
case "messages":
return navigationPageRoute((context) => const MessagesPage());
case "absences":
return navigationPageRoute((context) => const AbsencesPage());
case "home":
default:
return navigationPageRoute((context) => const HomePage());
}
}
Route navigationPageRoute(Widget Function(BuildContext) builder) {
return PageRouteBuilder(
pageBuilder: (context, _, __) => builder(context),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeThroughTransition(
fillColor: Theme.of(context).scaffoldBackgroundColor,
animation: animation,
secondaryAnimation: secondaryAnimation,
child: child,
);
},
);
}

View File

@@ -0,0 +1,137 @@
import 'dart:io';
import 'package:filcnaplo/api/providers/news_provider.dart';
import 'package:filcnaplo/api/providers/sync.dart';
import 'package:filcnaplo/theme/colors/colors.dart';
import 'package:filcnaplo/theme/observer.dart';
import 'package:filcnaplo_desktop_ui/screens/navigation/navigation_route.dart';
import 'package:filcnaplo_desktop_ui/screens/navigation/navigation_route_handler.dart';
import 'package:filcnaplo_desktop_ui/screens/navigation/sidebar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_acrylic/flutter_acrylic.dart';
import 'package:provider/provider.dart';
import 'package:filcnaplo/models/settings.dart';
import 'package:filcnaplo_kreta_api/client/client.dart';
class NavigationScreen extends StatefulWidget {
const NavigationScreen({Key? key}) : super(key: key);
static NavigationScreenState? of(BuildContext context) => context.findAncestorStateOfType<NavigationScreenState>();
@override
State<NavigationScreen> createState() => NavigationScreenState();
}
class NavigationScreenState extends State<NavigationScreen> with WidgetsBindingObserver {
final _navigatorState = GlobalKey<NavigatorState>();
late NavigationRoute selected;
late SettingsProvider settings;
late NewsProvider newsProvider;
double topInset = 0.0;
@override
void initState() {
super.initState();
settings = Provider.of<SettingsProvider>(context, listen: false);
selected = NavigationRoute();
selected.index = 0;
// add brightness observer
WidgetsBinding.instance.addObserver(this);
// set client User-Agent
Provider.of<KretaClient>(context, listen: false).userAgent = settings.config.userAgent;
// Get news
newsProvider = Provider.of<NewsProvider>(context, listen: false);
newsProvider.restore().then((value) => newsProvider.fetch());
// Initial sync
syncAll(context);
() async {
try {
await Window.initialize();
} catch (_) {}
// Transparent sidebar
await Window.setEffect(effect: WindowEffect.acrylic, color: Platform.isMacOS ? Colors.transparent : const Color.fromARGB(27, 27, 27, 27));
// todo: do for windows
if (Platform.isMacOS) {
topInset = await Window.getTitlebarHeight();
await Window.enableFullSizeContentView();
await Window.hideTitle();
await Window.makeTitlebarTransparent();
}
}();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangePlatformBrightness() {
if (settings.theme == ThemeMode.system) {
Brightness? brightness = WidgetsBinding.instance.window.platformBrightness;
Provider.of<ThemeModeObserver>(context, listen: false).changeTheme(brightness == Brightness.light ? ThemeMode.light : ThemeMode.dark);
}
super.didChangePlatformBrightness();
}
void setPage(String page) => setState(() => selected.name = page);
@override
Widget build(BuildContext context) {
settings = Provider.of<SettingsProvider>(context);
newsProvider = Provider.of<NewsProvider>(context);
// Show news
WidgetsBinding.instance.addPostFrameCallback((_) {
if (newsProvider.show) {
newsProvider.lock();
// NewsView.show(newsProvider.news[newsProvider.state], context: context).then((value) => newsProvider.release());
}
});
return Scaffold(
backgroundColor: Colors.transparent,
body: Row(
children: [
if (_navigatorState.currentState != null)
Container(
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor.withOpacity(.5),
border: Border(right: BorderSide(color: AppColors.of(context).shadow.withOpacity(.7), width: 1.0)),
),
child: Padding(
padding: EdgeInsets.only(top: topInset),
child: Sidebar(
navigator: _navigatorState.currentState!,
selected: selected.name,
onRouteChange: (name) => setPage(name),
),
),
),
Expanded(
child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: MediaQuery(
data: MediaQuery.of(context).copyWith(
padding: EdgeInsets.only(top: topInset),
),
child: Navigator(
key: _navigatorState,
initialRoute: selected.name,
onGenerateRoute: (settings) => navigationRouteHandler(settings),
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,304 @@
import 'package:animations/animations.dart';
import 'package:filcnaplo/api/providers/database_provider.dart';
import 'package:filcnaplo/api/providers/user_provider.dart';
import 'package:filcnaplo/icons/filc_icons.dart';
import 'package:filcnaplo/models/settings.dart';
import 'package:filcnaplo/utils/color.dart';
import 'package:filcnaplo_desktop_ui/common/panel_button.dart';
import 'package:filcnaplo_desktop_ui/common/profile_image.dart';
import 'package:filcnaplo_desktop_ui/screens/navigation/sidebar_action.dart';
import 'package:filcnaplo_desktop_ui/screens/settings/settings_screen.dart';
import 'package:filcnaplo_mobile_ui/screens/settings/accounts/account_tile.dart';
import 'package:filcnaplo_kreta_api/client/client.dart';
import 'package:filcnaplo_kreta_api/providers/absence_provider.dart';
import 'package:filcnaplo_kreta_api/providers/event_provider.dart';
import 'package:filcnaplo_kreta_api/providers/exam_provider.dart';
import 'package:filcnaplo_kreta_api/providers/grade_provider.dart';
import 'package:filcnaplo_kreta_api/providers/homework_provider.dart';
import 'package:filcnaplo_kreta_api/providers/message_provider.dart';
import 'package:filcnaplo_kreta_api/providers/note_provider.dart';
import 'package:filcnaplo_kreta_api/providers/timetable_provider.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:provider/provider.dart';
import 'package:filcnaplo/theme/colors/colors.dart';
class Sidebar extends StatefulWidget {
const Sidebar({Key? key, required this.navigator, required this.onRouteChange, this.selected = "home"}) : super(key: key);
final NavigatorState navigator;
final String selected;
final Function(String) onRouteChange;
@override
State<Sidebar> createState() => _SidebarState();
}
class _SidebarState extends State<Sidebar> {
late UserProvider user;
late SettingsProvider settings;
late String firstName;
String topNav = "";
bool expandAccount = false;
List<Widget> accountTiles = [];
@override
void initState() {
super.initState();
user = Provider.of<UserProvider>(context, listen: false);
}
Future<void> restore() => Future.wait([
Provider.of<GradeProvider>(context, listen: false).restore(),
Provider.of<TimetableProvider>(context, listen: false).restoreUser(),
Provider.of<ExamProvider>(context, listen: false).restore(),
Provider.of<HomeworkProvider>(context, listen: false).restore(),
Provider.of<MessageProvider>(context, listen: false).restore(),
Provider.of<NoteProvider>(context, listen: false).restore(),
Provider.of<EventProvider>(context, listen: false).restore(),
Provider.of<AbsenceProvider>(context, listen: false).restore(),
Provider.of<KretaClient>(context, listen: false).refreshLogin(),
]);
@override
Widget build(BuildContext context) {
user = Provider.of<UserProvider>(context);
settings = Provider.of<SettingsProvider>(context);
List<String> nameParts = user.name?.split(" ") ?? ["?"];
if (!settings.presentationMode) {
firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0];
} else {
firstName = "Béla";
}
List<Widget> pageWidgets = [
SidebarAction(
title: const Text("Home"),
icon: const Icon(FilcIcons.home),
selected: widget.selected == "home",
onTap: () {
if (widget.selected != "home") {
widget.navigator.pushReplacementNamed("home");
widget.onRouteChange("home");
}
},
),
SidebarAction(
title: const Text("Grades"),
icon: const Icon(FeatherIcons.bookmark),
selected: widget.selected == "grades",
onTap: () {
if (widget.selected != "grades") {
widget.navigator.pushReplacementNamed("grades");
widget.onRouteChange("grades");
}
},
),
SidebarAction(
title: const Text("Timetable"),
icon: const Icon(FeatherIcons.calendar),
selected: widget.selected == "timetable",
onTap: () {
if (widget.selected != "timetable") {
widget.navigator.pushReplacementNamed("timetable");
widget.onRouteChange("timetable");
}
},
),
SidebarAction(
title: const Text("Messages"),
icon: const Icon(FeatherIcons.messageSquare),
selected: widget.selected == "messages",
onTap: () {
if (widget.selected != "messages") {
widget.navigator.pushReplacementNamed("messages");
widget.onRouteChange("messages");
}
},
),
SidebarAction(
title: const Text("Absences"),
icon: const Icon(FeatherIcons.clock),
selected: widget.selected == "absences",
onTap: () {
if (widget.selected != "absences") {
widget.navigator.pushReplacementNamed("absences");
widget.onRouteChange("absences");
}
},
),
];
List<Widget> bottomActions = [
SidebarAction(
title: const Text("Settings"),
selected: true,
icon: const Icon(FeatherIcons.settings),
onTap: () {
if (topNav != "settings") {
widget.navigator.push(CupertinoPageRoute(builder: (context) => const SettingsScreen())).then((value) => topNav = "");
topNav = "settings";
}
},
),
];
buildAccountTiles();
List<Widget> accountWidgets = [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Column(children: accountTiles),
),
// Account settings
PanelButton(
onPressed: () {
Navigator.of(context).pushNamed("login_back");
},
title: const Text("Add User"),
leading: const Icon(FeatherIcons.userPlus),
),
PanelButton(
onPressed: () async {
String? userId = user.id;
if (userId == null) return;
// Delete User
user.removeUser(userId);
await Provider.of<DatabaseProvider>(context, listen: false).store.removeUser(userId);
// If no other Users left, go back to LoginScreen
if (user.getUsers().isNotEmpty) {
user.setUser(user.getUsers().first.id);
restore().then((_) => user.setUser(user.getUsers().first.id));
} else {
Navigator.of(context).pushNamedAndRemoveUntil("login", (_) => false);
}
},
title: const Text("Log Out"),
leading: Icon(FeatherIcons.logOut, color: AppColors.of(context).red),
),
];
return SizedBox(
height: double.infinity,
width: 250.0,
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 18.0, top: 18.0, bottom: 24.0, right: 8.0),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 12.0),
child: ProfileImage(
name: firstName,
radius: 18.0,
backgroundColor:
!settings.presentationMode ? ColorUtils.stringToColor(user.name ?? "?") : Theme.of(context).colorScheme.secondary,
),
),
Expanded(
child: Text(
firstName,
style: const TextStyle(
fontSize: 16.0,
fontWeight: FontWeight.w600,
),
),
),
PageTransitionSwitcher(
transitionBuilder: (child, primaryAnimation, secondaryAnimation) {
return FadeThroughTransition(
fillColor: Colors.transparent,
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
child: child,
);
},
child: IconButton(
key: Key(expandAccount ? "accounts" : "pages"),
icon: Icon(expandAccount ? FeatherIcons.chevronDown : FeatherIcons.chevronRight),
padding: EdgeInsets.zero,
splashRadius: 20.0,
onPressed: () {
setState(() {
expandAccount = !expandAccount;
});
},
),
),
],
),
),
// Pages
Expanded(
child: PageTransitionSwitcher(
duration: const Duration(milliseconds: 500),
transitionBuilder: (child, primaryAnimation, secondaryAnimation) {
return SharedAxisTransition(
fillColor: Colors.transparent,
animation: primaryAnimation,
secondaryAnimation: secondaryAnimation,
transitionType: SharedAxisTransitionType.scaled,
child: child,
);
},
child: !expandAccount
? Column(
key: const Key("pages"),
children: pageWidgets,
)
: Column(
key: const Key("accounts"),
children: accountWidgets,
),
),
),
// Settings
...bottomActions,
// Bottom padding
const SizedBox(height: 12.0),
],
),
);
}
void buildAccountTiles() {
accountTiles = [];
user.getUsers().forEach((account) {
if (account.id == user.id) return;
String _firstName;
List<String> _nameParts = user.name?.split(" ") ?? ["?"];
if (!settings.presentationMode) {
_firstName = _nameParts.length > 1 ? _nameParts[1] : _nameParts[0];
} else {
_firstName = "Béla";
}
accountTiles.add(AccountTile(
name: Text(!settings.presentationMode ? account.name : "Béla", style: const TextStyle(fontWeight: FontWeight.w500)),
username: Text(!settings.presentationMode ? account.username : "72469696969"),
profileImage: ProfileImage(
name: _firstName,
backgroundColor: !settings.presentationMode ? ColorUtils.stringToColor(account.name) : Theme.of(context).colorScheme.secondary,
role: account.role,
),
onTap: () {
user.setUser(account.id);
restore().then((_) => user.setUser(account.id));
},
// onTapMenu: () => _showBottomSheet(account),
));
});
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:filcnaplo/theme/colors/colors.dart';
class SidebarAction extends StatelessWidget {
const SidebarAction({Key? key, this.title, this.icon, this.onTap, this.selected = false}) : super(key: key);
final bool selected;
final Widget? icon;
final Widget? title;
final Function()? onTap;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 12.0),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12.0),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(
children: [
if (icon != null)
IconTheme(
data: IconThemeData(
color: AppColors.of(context).text.withOpacity(selected ? 1.0 : .3),
),
child: icon!,
),
if (title != null)
Padding(
padding: const EdgeInsets.only(left: 24.0),
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 500),
style: TextStyle(
color: AppColors.of(context).text.withOpacity(selected ? 1.0 : .8),
fontWeight: FontWeight.w500,
fontFamily: "Montserrat",
),
child: title!,
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
class NewsView extends StatelessWidget {
const NewsView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container();
}
}

View File

@@ -0,0 +1,725 @@
import 'package:filcnaplo/api/providers/update_provider.dart';
import 'package:filcnaplo/theme/colors/accent.dart';
import 'package:filcnaplo/theme/observer.dart';
import 'package:filcnaplo_kreta_api/providers/absence_provider.dart';
import 'package:filcnaplo_kreta_api/providers/event_provider.dart';
import 'package:filcnaplo_kreta_api/providers/exam_provider.dart';
import 'package:filcnaplo_kreta_api/providers/grade_provider.dart';
import 'package:filcnaplo_kreta_api/providers/homework_provider.dart';
import 'package:filcnaplo_kreta_api/providers/message_provider.dart';
import 'package:filcnaplo_kreta_api/providers/note_provider.dart';
import 'package:filcnaplo_kreta_api/providers/timetable_provider.dart';
import 'package:filcnaplo/api/providers/user_provider.dart';
import 'package:filcnaplo/utils/format.dart';
import 'package:filcnaplo/models/settings.dart';
import 'package:filcnaplo/models/user.dart';
import 'package:filcnaplo/theme/colors/colors.dart';
import 'package:filcnaplo_kreta_api/client/client.dart';
import 'package:filcnaplo_mobile_ui/common/action_button.dart';
import 'package:filcnaplo_mobile_ui/common/bottom_sheet_menu/bottom_sheet_menu.dart';
import 'package:filcnaplo_mobile_ui/common/bottom_sheet_menu/bottom_sheet_menu_item.dart';
import 'package:filcnaplo_mobile_ui/common/panel/panel.dart';
import 'package:filcnaplo_mobile_ui/common/panel/panel_button.dart';
import 'package:filcnaplo_mobile_ui/common/profile_image/profile_image.dart';
import 'package:filcnaplo_mobile_ui/common/widgets/update/updates_view.dart';
import 'package:filcnaplo_mobile_ui/premium/premium_button.dart';
import 'package:filcnaplo_mobile_ui/screens/news/news_screen.dart';
import 'package:filcnaplo_mobile_ui/screens/settings/accounts/account_tile.dart';
import 'package:filcnaplo_mobile_ui/screens/settings/accounts/account_view.dart';
import 'package:filcnaplo_mobile_ui/screens/settings/debug/subject_icon_gallery.dart';
import 'package:filcnaplo_mobile_ui/screens/settings/privacy_view.dart';
import 'package:filcnaplo_mobile_ui/screens/settings/settings_helper.dart';
import 'package:filcnaplo_premium/providers/premium_provider.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_custom_tabs/flutter_custom_tabs.dart' as tabs;
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:provider/provider.dart';
import 'package:filcnaplo/utils/color.dart';
import 'package:url_launcher/url_launcher.dart';
import 'settings_screen.i18n.dart';
import 'package:flutter/services.dart';
import 'package:filcnaplo_premium/ui/mobile/settings/nickname.dart';
import 'package:filcnaplo_premium/ui/mobile/settings/icon_pack.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({Key? key}) : super(key: key);
@override
_SettingsScreenState createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> with SingleTickerProviderStateMixin {
int devmodeCountdown = 3;
final bool __ss = false; // secret settings
late UserProvider user;
late UpdateProvider updateProvider;
late SettingsProvider settings;
late KretaClient kretaClient;
late String firstName;
List<Widget> accountTiles = [];
late AnimationController _hideContainersController;
Future<void> restore() => Future.wait([
Provider.of<GradeProvider>(context, listen: false).restore(),
Provider.of<TimetableProvider>(context, listen: false).restoreUser(),
Provider.of<ExamProvider>(context, listen: false).restore(),
Provider.of<HomeworkProvider>(context, listen: false).restore(),
Provider.of<MessageProvider>(context, listen: false).restore(),
Provider.of<NoteProvider>(context, listen: false).restore(),
Provider.of<EventProvider>(context, listen: false).restore(),
Provider.of<AbsenceProvider>(context, listen: false).restore(),
Provider.of<KretaClient>(context, listen: false).refreshLogin(),
]);
void buildAccountTiles() {
accountTiles = [];
user.getUsers().forEach((account) {
if (account.id == user.id) return;
String _firstName;
List<String> _nameParts = user.displayName?.split(" ") ?? ["?"];
if (!settings.presentationMode) {
_firstName = _nameParts.length > 1 ? _nameParts[1] : _nameParts[0];
} else {
_firstName = "Béla";
}
accountTiles.add(AccountTile(
name: Text(!settings.presentationMode ? account.name : "Béla", style: const TextStyle(fontWeight: FontWeight.w500)),
username: Text(!settings.presentationMode ? account.username : "72469696969"),
profileImage: ProfileImage(
name: _firstName,
backgroundColor: !settings.presentationMode ? ColorUtils.stringToColor(account.name) : Theme.of(context).colorScheme.secondary,
role: account.role,
),
onTap: () {
user.setUser(account.id);
restore().then((_) => user.setUser(account.id));
Navigator.of(context).pop();
},
onTapMenu: () => _showBottomSheet(account),
));
});
}
void _showBottomSheet(User u) {
showBottomSheetMenu(context, items: [
BottomSheetMenuItem(
onPressed: () => AccountView.show(u, context: context),
icon: const Icon(FeatherIcons.user),
title: Text("personal_details".i18n),
),
BottomSheetMenuItem(
onPressed: () => _openDKT(u),
icon: Icon(FeatherIcons.grid, color: AppColors.of(context).teal),
title: Text("open_dkt".i18n),
),
const UserMenuNickname(),
// BottomSheetMenuItem(
// onPressed: () {},
// icon: Icon(FeatherIcons.camera),
// title: Text("edit_profile_picture".i18n),
// ),
// BottomSheetMenuItem(
// onPressed: () {},
// icon: Icon(FeatherIcons.trash2, color: AppColors.of(context).red),
// title: Text("remove_profile_picture".i18n),
// ),
]);
}
void _openDKT(User u) => tabs.launch("https://dkttanulo.e-kreta.hu/sso?id_token=${kretaClient.idToken}",
customTabsOption: tabs.CustomTabsOption(
toolbarColor: Theme.of(context).scaffoldBackgroundColor,
showPageTitle: true,
));
@override
void initState() {
super.initState();
_hideContainersController = AnimationController(vsync: this, duration: const Duration(milliseconds: 200));
}
@override
Widget build(BuildContext context) {
user = Provider.of<UserProvider>(context);
settings = Provider.of<SettingsProvider>(context);
updateProvider = Provider.of<UpdateProvider>(context);
kretaClient = Provider.of<KretaClient>(context);
List<String> nameParts = user.displayName?.split(" ") ?? ["?"];
if (!settings.presentationMode) {
firstName = nameParts.length > 1 ? nameParts[1] : nameParts[0];
} else {
firstName = "Béla";
}
String startPageTitle = SettingsHelper.localizedPageTitles()[settings.startPage] ?? "?";
String themeModeText = {ThemeMode.light: "light".i18n, ThemeMode.dark: "dark".i18n, ThemeMode.system: "system".i18n}[settings.theme] ?? "?";
String languageText = SettingsHelper.langMap[settings.language] ?? "?";
String vibrateTitle = {
VibrationStrength.off: "voff".i18n,
VibrationStrength.light: "vlight".i18n,
VibrationStrength.medium: "vmedium".i18n,
VibrationStrength.strong: "vstrong".i18n,
}[settings.vibrate] ??
"?";
buildAccountTiles();
if (settings.developerMode) devmodeCountdown = -1;
return Scaffold(
body: Padding(
padding: const EdgeInsets.only(top: 11.5),
child: CustomScrollView(
slivers: [
SliverAppBar(
pinned: true,
floating: true,
snap: false,
centerTitle: false,
title: const Text("Settings"),
surfaceTintColor: Theme.of(context).scaffoldBackgroundColor,
),
SliverToBoxAdapter(
child: AnimatedBuilder(
animation: _hideContainersController,
builder: (context, child) => Opacity(
opacity: 1 - _hideContainersController.value,
child: SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 32.0),
// Updates
if (updateProvider.available)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0),
child: Panel(
child: PanelButton(
onPressed: () => _openUpdates(context),
title: Text("update_available".i18n),
leading: const Icon(FeatherIcons.download),
trailing: Text(
updateProvider.releases.first.tag,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.secondary,
),
),
),
),
),
// const Padding(
// padding: EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0),
// child: PremiumBannerButton(),
// ),
if (!Provider.of<PremiumProvider>(context).hasPremium)
const ClipRect(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: PremiumButton(),
),
),
// General Settings
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0),
child: Panel(
title: Text("general".i18n),
child: Column(
children: [
PanelButton(
onPressed: () {
SettingsHelper.language(context);
setState(() {});
},
title: Text("language".i18n),
leading: const Icon(FeatherIcons.globe),
trailing: Text(languageText),
),
PanelButton(
onPressed: () {
SettingsHelper.startPage(context);
setState(() {});
},
title: Text("startpage".i18n),
leading: const Icon(FeatherIcons.play),
trailing: Text(startPageTitle.capital()),
),
PanelButton(
onPressed: () {
SettingsHelper.rounding(context);
setState(() {});
},
title: Text("rounding".i18n),
leading: const Icon(FeatherIcons.gitCommit),
trailing: Text((settings.rounding / 10).toStringAsFixed(1)),
),
PanelButton(
onPressed: () {
SettingsHelper.vibrate(context);
setState(() {});
},
title: Text("vibrate".i18n),
leading: const Icon(FeatherIcons.radio),
trailing: Text(vibrateTitle),
),
PanelButton(
padding: const EdgeInsets.only(left: 14.0),
onPressed: () {
SettingsHelper.bellDelay(context);
setState(() {});
},
title: Text(
"bell_delay".i18n,
style: TextStyle(color: AppColors.of(context).text.withOpacity(settings.bellDelayEnabled ? 1.0 : .5)),
),
leading: settings.bellDelayEnabled
? const Icon(FeatherIcons.bell)
: Icon(FeatherIcons.bellOff, color: AppColors.of(context).text.withOpacity(.25)),
trailingDivider: true,
trailing: Switch(
onChanged: (v) => settings.update(bellDelayEnabled: v),
value: settings.bellDelayEnabled,
activeColor: Theme.of(context).colorScheme.secondary,
),
),
],
),
),
),
if (kDebugMode)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0),
child: Panel(
title: const Text("Debug"),
child: Column(
children: [
PanelButton(
title: const Text("Subject Icon Gallery"),
leading: const Icon(CupertinoIcons.rectangle_3_offgrid_fill),
trailing: const Icon(Icons.arrow_forward),
onPressed: () {
Navigator.of(context, rootNavigator: true).push(
CupertinoPageRoute(builder: (context) => const SubjectIconGallery()),
);
},
)
],
),
),
),
// Secret Settings
if (__ss)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0),
child: Panel(
title: Text("secret".i18n),
child: Column(
children: [
// Good student mode
Material(
type: MaterialType.transparency,
child: SwitchListTile(
contentPadding: const EdgeInsets.only(left: 12.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
title: Text("goodstudent".i18n, style: const TextStyle(fontWeight: FontWeight.w500)),
onChanged: (v) {
if (v) {
showDialog(
context: context,
builder: (context) => WillPopScope(
onWillPop: () async => false,
child: AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
title: Text("attention".i18n),
content: Text("goodstudent_disclaimer".i18n),
actions: [
ActionButton(
label: "understand".i18n,
onTap: () {
Navigator.of(context).pop();
settings.update(goodStudent: v);
Provider.of<GradeProvider>(context, listen: false).fetch();
})
],
),
),
);
} else {
settings.update(goodStudent: v);
Provider.of<GradeProvider>(context, listen: false).fetch();
}
},
value: settings.goodStudent,
activeColor: Theme.of(context).colorScheme.secondary,
),
),
// Presentation mode
Material(
type: MaterialType.transparency,
child: SwitchListTile(
contentPadding: const EdgeInsets.only(left: 12.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
title: const Text("Presentation Mode", style: TextStyle(fontWeight: FontWeight.w500)),
onChanged: (v) => settings.update(presentationMode: v),
value: settings.presentationMode,
activeColor: Theme.of(context).colorScheme.secondary,
),
),
],
),
),
),
// Theme Settings
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0),
child: Panel(
title: Text("appearance".i18n),
child: Column(
children: [
PanelButton(
onPressed: () {
SettingsHelper.theme(context);
setState(() {});
},
title: Text("theme".i18n),
leading: const Icon(FeatherIcons.sun),
trailing: Text(themeModeText),
),
PanelButton(
onPressed: () async {
await _hideContainersController.forward();
SettingsHelper.accentColor(context);
setState(() {});
_hideContainersController.reset();
},
title: Text("color".i18n),
leading: const Icon(FeatherIcons.droplet),
trailing: Container(
width: 12.0,
height: 12.0,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondary,
shape: BoxShape.circle,
),
),
),
PanelButton(
onPressed: () {
SettingsHelper.gradeColors(context);
setState(() {});
},
title: Text("grade_colors".i18n),
leading: const Icon(FeatherIcons.star),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(
5,
(i) => Container(
margin: const EdgeInsets.only(left: 2.0),
width: 12.0,
height: 12.0,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: settings.gradeColors[i],
),
),
),
),
),
Material(
type: MaterialType.transparency,
child: SwitchListTile(
contentPadding: const EdgeInsets.only(left: 12.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
title: Row(
children: [
Icon(
FeatherIcons.barChart,
color: settings.graphClassAvg
? Theme.of(context).colorScheme.secondary
: AppColors.of(context).text.withOpacity(.25),
),
const SizedBox(width: 24.0),
Expanded(
child: Text(
"graph_class_avg".i18n,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16.0,
color: AppColors.of(context).text.withOpacity(settings.graphClassAvg ? 1.0 : .5),
),
),
),
],
),
onChanged: (v) => settings.update(graphClassAvg: v),
value: settings.graphClassAvg,
activeColor: Theme.of(context).colorScheme.secondary,
),
),
const PremiumIconPackSelector(),
],
),
),
),
// Notifications
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0),
child: Panel(
title: Text("notifications".i18n),
child: Material(
type: MaterialType.transparency,
child: SwitchListTile(
contentPadding: const EdgeInsets.only(left: 12.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
title: Row(
children: [
Icon(
Icons.newspaper_outlined,
color: settings.newsEnabled
? Theme.of(context).colorScheme.secondary
: AppColors.of(context).text.withOpacity(.25),
),
const SizedBox(width: 24.0),
Expanded(
child: Text(
"news".i18n,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16.0,
color: AppColors.of(context).text.withOpacity(settings.newsEnabled ? 1.0 : .5),
),
),
),
],
),
onChanged: (v) => settings.update(newsEnabled: v),
value: settings.newsEnabled,
activeColor: Theme.of(context).colorScheme.secondary,
),
),
),
),
// Extras
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0),
child: Panel(
title: Text("extras".i18n),
child: Column(children: [
Material(
type: MaterialType.transparency,
child: SwitchListTile(
contentPadding: const EdgeInsets.only(left: 12.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
title: Row(
children: [
Icon(
FeatherIcons.gift,
color: settings.gradeOpeningFun
? Theme.of(context).colorScheme.secondary
: AppColors.of(context).text.withOpacity(.25),
),
const SizedBox(width: 24.0),
Expanded(
child: Text(
"surprise_grades".i18n,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16.0,
color: AppColors.of(context).text.withOpacity(settings.gradeOpeningFun ? 1.0 : .5),
),
),
),
],
),
onChanged: (v) => settings.update(gradeOpeningFun: v),
value: settings.gradeOpeningFun,
activeColor: Theme.of(context).colorScheme.secondary,
),
),
]),
),
),
// About
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0),
child: Panel(
title: Text("about".i18n),
child: Column(children: [
PanelButton(
leading: const Icon(FeatherIcons.atSign),
title: const Text("Discord"),
onPressed: () => launchUrl(Uri.parse("https://filcnaplo.hu/discord"), mode: LaunchMode.externalApplication),
),
PanelButton(
leading: const Icon(FeatherIcons.globe),
title: const Text("www.filcnaplo.hu"),
onPressed: () => launchUrl(Uri.parse("https://filcnaplo.hu"), mode: LaunchMode.externalApplication),
),
PanelButton(
leading: const Icon(FeatherIcons.github),
title: const Text("Github"),
onPressed: () => launchUrl(Uri.parse("https://github.com/filc"), mode: LaunchMode.externalApplication),
),
PanelButton(
leading: const Icon(FeatherIcons.mail),
title: Text("news".i18n),
onPressed: () => _openNews(context),
),
PanelButton(
leading: const Icon(FeatherIcons.lock),
title: Text("privacy".i18n),
onPressed: () => _openPrivacy(context),
),
PanelButton(
leading: const Icon(FeatherIcons.award),
title: Text("licenses".i18n),
onPressed: () => showLicensePage(context: context),
),
Tooltip(
message: "data_collected".i18n,
padding: const EdgeInsets.all(4.0),
textStyle: TextStyle(fontWeight: FontWeight.w500, color: AppColors.of(context).text),
decoration: BoxDecoration(color: Theme.of(context).colorScheme.background),
child: Material(
type: MaterialType.transparency,
child: SwitchListTile(
contentPadding: const EdgeInsets.only(left: 12.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
secondary: Icon(
FeatherIcons.barChart2,
color: settings.xFilcId != "none"
? Theme.of(context).colorScheme.secondary
: AppColors.of(context).text.withOpacity(.25),
),
title: Text(
"Analytics".i18n,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16.0,
color: AppColors.of(context).text.withOpacity(settings.xFilcId != "none" ? 1.0 : .5),
),
),
subtitle: Text(
"Anonymous Usage Analytics".i18n,
style: TextStyle(
color: AppColors.of(context).text.withOpacity(settings.xFilcId != "none" ? .5 : .2),
),
),
onChanged: (v) {
String newId;
if (v == false) {
newId = "none";
} else if (settings.xFilcId == "none") {
newId = SettingsProvider.defaultSettings().xFilcId;
} else {
newId = settings.xFilcId;
}
settings.update(xFilcId: newId);
},
value: settings.xFilcId != "none",
activeColor: Theme.of(context).colorScheme.secondary,
),
),
),
]),
),
),
if (settings.developerMode)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0),
child: Panel(
title: const Text("Developer Settings"),
child: Column(
children: [
Material(
type: MaterialType.transparency,
child: SwitchListTile(
contentPadding: const EdgeInsets.only(left: 12.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
title: const Text("Developer Mode", style: TextStyle(fontWeight: FontWeight.w500)),
onChanged: (v) => settings.update(developerMode: false),
value: settings.developerMode,
activeColor: Theme.of(context).colorScheme.secondary,
),
),
PanelButton(
leading: const Icon(FeatherIcons.copy),
title: const Text("Copy JWT"),
onPressed: () =>
Clipboard.setData(ClipboardData(text: Provider.of<KretaClient>(context, listen: false).accessToken)),
),
if (Provider.of<PremiumProvider>(context, listen: false).hasPremium)
PanelButton(
leading: const Icon(FeatherIcons.key),
title: const Text("Remove Premium"),
onPressed: () {
Provider.of<PremiumProvider>(context, listen: false).activate(removePremium: true);
settings.update(accentColor: AccentColor.filc, store: true);
Provider.of<ThemeModeObserver>(context, listen: false).changeTheme(settings.theme);
},
),
],
),
),
),
SafeArea(
top: false,
child: Center(
child: GestureDetector(
child: const Panel(title: Text("v" + String.fromEnvironment("APPVER", defaultValue: "?"))),
onTap: () {
if (devmodeCountdown > 0) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
duration: const Duration(milliseconds: 200),
content: Text("You are $devmodeCountdown taps away from Developer Mode."),
));
setState(() => devmodeCountdown--);
} else if (devmodeCountdown == 0) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text("Developer Mode successfully activated."),
));
settings.update(developerMode: true);
setState(() => devmodeCountdown--);
}
},
),
),
),
],
),
),
),
),
)
],
),
),
);
}
void _openNews(BuildContext context) =>
Navigator.of(context, rootNavigator: true).push(CupertinoPageRoute(builder: (context) => const NewsScreen()));
void _openUpdates(BuildContext context) => UpdateView.show(updateProvider.releases.first, context: context);
void _openPrivacy(BuildContext context) => PrivacyView.show(context);
}

View File

@@ -0,0 +1,188 @@
import 'package:i18n_extension/i18n_extension.dart';
extension SettingsLocalization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"personal_details": "Personal Details",
"open_dkt": "Open DKT",
"edit_nickname": "Edit Nickname",
"edit_profile_picture": "Edit Profile Picture",
"remove_profile_picture": "Remove Profile Picture",
"light": "Light",
"dark": "Dark",
"system": "System",
"add_user": "Add User",
"log_out": "Log Out",
"update_available": "Update Available",
"general": "General",
"language": "Language",
"startpage": "Startpage",
"rounding": "Rounding",
"appearance": "Appearance",
"theme": "Theme",
"color": "Color",
"grade_colors": "Grade Colors",
"notifications": "Notifications",
"news": "News",
"extras": "Extras",
"about": "About",
"supporters": "Supporters",
"privacy": "Privacy Policy",
"licenses": "Licenses",
"vibrate": "Vibration",
"voff": "Off",
"vlight": "Light",
"vmedium": "Medium",
"vstrong": "Strong",
"cancel": "Cancel",
"done": "Done",
"reset": "Reset",
"open": "Open",
"data_collected": "Data collected: Platform (eg. Android), App version (eg. 3.0.0), Unique Install Identifier",
"Analytics": "Analytics",
"Anonymous Usage Analytics": "Anonymous Usage Analytics",
"graph_class_avg": "Class average on graph",
"goodstudent": "Good student mode",
"attention": "Attention!",
"goodstudent_disclaimer":
"Filc Napló® Informatikai Zrt. can not be held liable for the usage of this feature.\n\n(if your mother beats you up because you showed her fake grades, you can only blame yourself for it)",
"understand": "I understand",
"secret": "Secret Settings",
"bell_delay": "Bell Delay",
"delay": "Delay",
"hurry": "Hurry",
"sync": "Synchronize",
"sync_help": "Press the Synchronize button when the bell rings.",
"surprise_grades": "Surprise Grades",
"icon_pack": "Icon Pack",
"change_username": "Set a nickname",
"Accent Color": "Accent Color",
"Background Color": "Background Color",
"Highlight Color": "Highlight Color",
"Adaptive Theme": "Adaptive Theme",
},
"hu_hu": {
"personal_details": "Személyes információk",
"open_dkt": "DKT megnyitása",
"edit_nickname": "Becenév szerkesztése",
"edit_profile_picture": "Profil-kép szerkesztése",
"remove_profile_picture": "Profil-kép törlése",
"light": "Világos",
"dark": "Sötét",
"system": "Rendszer",
"add_user": "Felhasználó hozzáadása",
"log_out": "Kijelentkezés",
"update_available": "Frissítés elérhető",
"general": "Általános",
"language": "Nyelv",
"startpage": "Kezdőlap",
"rounding": "Kerekítés",
"appearance": "Kinézet",
"theme": "Téma",
"color": "Színek",
"grade_colors": "Jegyek színei",
"notifications": "Értesítések",
"news": "Hírek",
"extras": "Extrák",
"about": "Névjegy",
"supporters": "Támogatók",
"privacy": "Adatvédelmi irányelvek",
"licenses": "Licenszek",
"vibrate": "Rezgés",
"voff": "Kikapcsolás",
"vlight": "Alacsony",
"vmedium": "Közepes",
"vstrong": "Erős",
"cancel": "Mégsem",
"done": "Kész",
"reset": "Visszaállítás",
"open": "Megnyitás",
"data_collected": "Gyűjtött adat: Platform (pl. Android), App verzió (pl. 3.0.0), Egyedi telepítési azonosító",
"Analytics": "Analitika",
"Anonymous Usage Analytics": "Névtelen használati analitika",
"graph_class_avg": "Osztályátlag a grafikonon",
"goodstudent": "Jó tanuló mód",
"attention": "Figyelem!",
"goodstudent_disclaimer":
"A Filc Napló® Informatikai Zrt. minden felelősséget elhárít a funkció használatával kapcsolatban.\n\n(Értsd: ha az anyád megver, mert megtévesztő ábrákat mutattál neki, azért csakis magadadat hibáztathatod.)",
"understand": "Értem",
"secret": "Titkos Beállítások",
"bell_delay": "Csengő eltolódása",
"delay": "Késleltetés",
"hurry": "Siettetés",
"sync": "Szinkronizálás",
"sync_help": "Csengetéskor nyomd meg a Szinkronizálás gombot.",
"surprise_grades": "Meglepetés jegyek",
"icon_pack": "Ikon séma",
"change_username": "Becenév beállítása",
"Accent Color": "Egyedi szín",
"Background Color": "Háttér színe",
"Highlight Color": "Panelek színe",
"Adaptive Theme": "Adaptív téma",
},
"de_de": {
"personal_details": "Persönliche Angaben",
"open_dkt": "Öffnen DKT",
"edit_nickname": "Spitznamen bearbeiten",
"edit_profile_picture": "Profilbild bearbeiten",
"remove_profile_picture": "Profilbild entfernen",
"light": "Licht",
"dark": "Dunkel",
"system": "System",
"add_user": "Benutzer hinzufügen",
"log_out": "Abmelden",
"update_available": "Update verfügbar",
"general": "Allgemein",
"language": "Sprache",
"startpage": "Startseite",
"rounding": "Rundung",
"appearance": "Erscheinungsbild",
"theme": "Thema",
"color": "Farbe",
"grade_colors": "Grad Farben",
"notifications": "Benachrichtigungen",
"news": "Nachrichten",
"extras": "Extras",
"about": "Informationen",
"supporters": "Unterstützer",
"privacy": "Datenschutzbestimmungen",
"licenses": "Lizenzen",
"vibrate": "Vibration",
"voff": "Aus",
"vlight": "Leicht",
"vmedium": "Mittel",
"vstrong": "Stark",
"cancel": "Abbrechen",
"done": "Fertig",
"reset": "Zurücksetzen",
"open": "Öffnen",
"data_collected": "Erhobene Daten: Plattform (z.B. Android), App version (z.B. 3.0.0), Eindeutige Installationskennung",
"Analytics": "Analytik",
"Anonymous Usage Analytics": "Anonyme Nutzungsanalyse",
"graph_class_avg": "Klassendurchschnitt in der Grafik",
"goodstudent": "Guter Student Modus",
"attention": "Achtung!",
"goodstudent_disclaimer": "Same in English.",
"understand": "Ich verstehe",
"secret": "Geheime Einstellungen",
"bell_delay": "Klingelverzögerung",
"delay": "Verzögern",
"hurry": "Eile",
"sync": "Synchronisieren",
"sync_help": "Drücken Sie die Sync-Taste, wenn die Glocke läutet.",
"surprise_grades": "Überraschungsnoten",
"icon_pack": "Icon-Pack",
"change_username": "Einen Spitznamen festlegen",
"Accent Color": "Accent Color",
"Background Color": "Background Color",
"Highlight Color": "Highlight Color",
"Adaptive Theme": "Adaptive Theme",
},
};
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);
}