changed everything from filcnaplo to refilc finally

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

29
refilc_mobile_ui/LICENSE Normal file
View File

@@ -0,0 +1,29 @@
BSD 3-Clause License
Copyright (c) 2023, reFilc
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

View File

@@ -0,0 +1,28 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at
# https://dart-lang.github.io/linter/lints/index.html.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class ActionButton extends StatelessWidget {
const ActionButton({super.key, required this.label, this.activeColor, this.onTap});
final Color? activeColor;
final void Function()? onTap;
final String label;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 6.0, bottom: 6.0, right: 3.0),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6.0),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
height: 32.0,
decoration: BoxDecoration(
color: (activeColor ?? Theme.of(context).colorScheme.secondary).withOpacity(0.25),
borderRadius: BorderRadius.circular(6.0),
),
padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 12.0),
child: Center(
child: Text(label,
maxLines: 1,
softWrap: false,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 15.0, fontWeight: FontWeight.w600, color: activeColor ?? Theme.of(context).colorScheme.secondary))),
),
),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc/ui/widgets/grade/grade_tile.dart';
import 'package:flutter/material.dart';
import 'package:i18n_extension/i18n_widget.dart';
class AverageDisplay extends StatelessWidget {
const AverageDisplay({super.key, this.average = 0.0, this.border = false});
final double average;
final bool border;
@override
Widget build(BuildContext context) {
Color color = average == 0.0
? AppColors.of(context).text.withOpacity(.8)
: gradeColor(context: context, value: average);
String averageText = average.toStringAsFixed(2);
if (I18n.of(context).locale.languageCode != "en") {
averageText = averageText.replaceAll(".", ",");
}
return Container(
width: border ? 57.0 : 54.0,
padding: EdgeInsets.symmetric(
horizontal: 6.0 - (border ? 2 : 0), vertical: 5.0 - (border ? 2 : 0)),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(45.0),
border: border
? Border.fromBorderSide(
BorderSide(color: color.withOpacity(.5), width: 1.0))
: null,
color: !border ? color.withOpacity(average == 0.0 ? .15 : .25) : null,
),
child: Text(
average == 0.0 ? "-" : averageText,
textAlign: TextAlign.center,
style: TextStyle(
color: color, fontWeight: FontWeight.w600, fontSize: 15.0),
maxLines: 1,
),
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
class BetaChip extends StatelessWidget {
const BetaChip({super.key, this.disabled = false});
final bool disabled;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 25,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: !disabled
? Theme.of(context).colorScheme.secondary
: AppColors.of(context).text.withOpacity(.25),
borderRadius: BorderRadius.circular(40),
),
child: Padding(
padding: const EdgeInsets.only(left: 8, right: 8),
child: Center(
child: Text(
"BETA",
softWrap: true,
style: TextStyle(
fontSize: 10,
color: disabled
? AppColors.of(context).text.withOpacity(.5)
: Colors.white,
fontWeight: FontWeight.w600,
overflow: TextOverflow.ellipsis,
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,51 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
class BottomCard extends StatelessWidget {
const BottomCard({super.key, this.child});
final Widget? child;
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14.0),
color: Theme.of(context).colorScheme.background,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 42.0,
height: 4.0,
margin: const EdgeInsets.only(top: 12.0, bottom: 4.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(45.0),
color: AppColors.of(context).text.withOpacity(0.10),
),
),
if (child != null) child!,
],
),
),
),
);
}
}
Future<void> showBottomCard({
required BuildContext context,
Widget? child,
bool rootNavigator = true,
}) async =>
await showModalBottomSheet(
backgroundColor: const Color(0x00000000),
useRootNavigator: rootNavigator,
elevation: 0,
isDismissible: true,
context: context,
builder: (context) => BottomCard(child: child));

View File

@@ -0,0 +1,23 @@
import 'package:refilc_mobile_ui/common/bottom_sheet_menu/rounded_bottom_sheet.dart';
import 'package:flutter/material.dart';
class BottomSheetMenu extends StatelessWidget {
const BottomSheetMenu({super.key, this.items = const []});
final List<Widget> items;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: items,
),
);
}
}
void showBottomSheetMenu(BuildContext context,
{List<Widget> items = const []}) =>
showRoundedModalBottomSheet(context, child: BottomSheetMenu(items: items));

View File

@@ -0,0 +1,20 @@
import 'package:refilc_mobile_ui/common/panel/panel_button.dart';
import 'package:flutter/material.dart';
class BottomSheetMenuItem extends StatelessWidget {
const BottomSheetMenuItem(
{super.key, required this.onPressed, required this.title, this.icon});
final void Function()? onPressed;
final Widget? title;
final Widget? icon;
@override
Widget build(BuildContext context) {
return PanelButton(
onPressed: onPressed,
leading: icon,
title: title,
);
}
}

View File

@@ -0,0 +1,75 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
class RoundedBottomSheet extends StatelessWidget {
const RoundedBottomSheet(
{super.key,
this.child,
this.borderRadius = 12.0,
this.shrink = true,
this.showHandle = true});
final Widget? child;
final double borderRadius;
final bool shrink;
final bool showHandle;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 500),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(borderRadius),
topRight: Radius.circular(borderRadius),
),
),
child: SafeArea(
child: Column(
mainAxisSize: shrink ? MainAxisSize.min : MainAxisSize.max,
children: [
if (showHandle)
Container(
width: 42.0,
height: 4.0,
margin: const EdgeInsets.only(top: 12.0, bottom: 4.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(45.0),
color: AppColors.of(context).text.withOpacity(0.10),
),
),
if (child != null) child!,
SizedBox(height: MediaQuery.of(context).padding.bottom),
],
),
),
);
}
}
Future<T?> showRoundedModalBottomSheet<T>(
BuildContext context, {
required Widget child,
bool rootNavigator = true,
}) async {
return await showModalBottomSheet<T>(
context: context,
backgroundColor: const Color(0x00000000),
elevation: 0,
isDismissible: true,
useRootNavigator: rootNavigator,
builder: (context) => RoundedBottomSheet(child: child));
}
PersistentBottomSheetController<T> showRoundedBottomSheet<T>(
BuildContext context, {
required Widget child,
}) {
return showBottomSheet<T>(
context: context,
backgroundColor: const Color(0x00000000),
elevation: 12.0,
builder: (context) => RoundedBottomSheet(child: child),
);
}

View File

@@ -0,0 +1,43 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
// ignore: non_constant_identifier_names
SnackBar CustomSnackBar({
required Widget content,
required BuildContext context,
Brightness? brightness,
Color? backgroundColor,
Duration? duration,
}) {
// backgroundColor > Brightness > Theme Background
// ignore: no_leading_underscores_for_local_identifiers
Color _backgroundColor = backgroundColor ??
(AppColors.fromBrightness(brightness ?? Theme.of(context).brightness)
.highlight);
Color textColor =
AppColors.fromBrightness(brightness ?? Theme.of(context).brightness).text;
return SnackBar(
duration: duration ?? const Duration(seconds: 4),
content: Container(
decoration: BoxDecoration(
color: _backgroundColor,
borderRadius: BorderRadius.circular(6.0),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(.15), blurRadius: 4.0)
],
),
padding: const EdgeInsets.all(12.0),
child: DefaultTextStyle(
style: Theme.of(context)
.textTheme
.bodyMedium!
.copyWith(color: textColor, fontWeight: FontWeight.w500),
child: content,
),
),
backgroundColor: const Color(0x00000000),
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0),
);
}

View File

@@ -0,0 +1,38 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
class Detail extends StatelessWidget {
const Detail(
{super.key,
required this.title,
required this.description,
this.maxLines = 3});
final String title;
final String description;
final int? maxLines;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 18.0),
child: SelectableText.rich(
TextSpan(
text: "$title: ",
style: TextStyle(
fontWeight: FontWeight.w600, color: AppColors.of(context).text),
children: [
TextSpan(
text: description,
style: TextStyle(
fontWeight: FontWeight.w500,
color: AppColors.of(context).text.withOpacity(0.85)),
),
],
),
minLines: 1,
maxLines: maxLines,
),
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter/material.dart';
class DialogButton extends StatelessWidget {
const DialogButton({super.key, required this.label, this.onTap});
final String label;
final Function()? onTap;
@override
Widget build(BuildContext context) {
return RawMaterialButton(
onPressed: onTap,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
child: Text(
label.toUpperCase(),
style: TextStyle(
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.secondary,
),
),
);
}
}

View File

@@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
class Dot extends StatelessWidget {
final Color color;
final double size;
const Dot({super.key, this.color = Colors.grey, this.size = 16.0});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
width: size,
height: size,
);
}
}

View File

@@ -0,0 +1,54 @@
import 'dart:math';
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
List<String> faces = [
"(·.·)",
"(≥o≤)",
"(·_·)",
"(˚Δ˚)b",
"(^-^*)",
"(='X'=)",
"(>_<)",
"(;-;)",
"\\(^Д^)/",
"\\(o_o)/",
];
class Empty extends StatelessWidget {
const Empty({super.key, this.subtitle});
final String? subtitle;
@override
Widget build(BuildContext context) {
int index = Random(DateTime.now().minute).nextInt(faces.length);
return Center(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Text.rich(
TextSpan(
text: faces[index],
style: TextStyle(
fontSize: 32.0,
fontWeight: FontWeight.w500,
color: AppColors.of(context).text.withOpacity(.75)),
children: subtitle != null
? [
TextSpan(
text: "\n${subtitle!}",
style: TextStyle(
fontSize: 18.0,
height: 2.0,
color: AppColors.of(context).text.withOpacity(.5)))
]
: [],
),
textAlign: TextAlign.center,
),
),
);
}
}

View File

@@ -0,0 +1,130 @@
import 'dart:math';
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
class FilterBar extends StatefulWidget implements PreferredSizeWidget {
const FilterBar({
super.key,
required this.items,
required this.controller,
this.onTap,
this.padding = const EdgeInsets.symmetric(horizontal: 24.0),
this.disableFading = false,
this.scrollable = true,
this.censored = false,
this.tabAlignment = TabAlignment.start,
}) : assert(items.length == controller.length);
final List<Widget> items;
final TabController controller;
final EdgeInsetsGeometry padding;
final Function(int)? onTap;
final bool disableFading;
final bool scrollable;
final bool censored;
final TabAlignment tabAlignment;
@override
final Size preferredSize = const Size.fromHeight(42.0);
@override
State<FilterBar> createState() => _FilterBarState();
}
class _FilterBarState extends State<FilterBar> {
List<double> censoredItemsWidth = [];
@override
void initState() {
super.initState();
censoredItemsWidth = List.generate(
widget.items.length, (index) => 25 + Random().nextDouble() * 50)
.toList();
}
@override
Widget build(BuildContext context) {
final tabbar = TabBar(
controller: widget.controller,
isScrollable: widget.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
indicatorSize: TabBarIndicatorSize.tab,
indicatorPadding: const EdgeInsets.symmetric(vertical: 8.0),
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: widget.censored
? censoredItemsWidth
.map(
(e) => Tab(
child: Container(
width: e,
height: 15,
decoration: BoxDecoration(
color: AppColors.of(context).text.withOpacity(.45),
borderRadius: BorderRadius.circular(8.0),
),
),
),
)
.toList()
: widget.items,
onTap: widget.onTap,
tabAlignment: widget.tabAlignment,
);
return Container(
width: MediaQuery.of(context).size.width,
height: 48.0,
padding: widget.padding,
child: widget.disableFading
? tabbar
: AnimatedBuilder(
animation: widget.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 = widget.controller.animation!.value;
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
index < 0.2 ? Colors.transparent : bg,
Colors.transparent,
Colors.transparent,
index > widget.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,35 @@
import 'package:flutter/material.dart';
class HeroDialogRoute<T> extends PageRoute<T> {
HeroDialogRoute({required this.builder}) : super();
final WidgetBuilder builder;
@override
bool get opaque => false;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => "livecard";
@override
Duration get transitionDuration => const Duration(milliseconds: 250);
@override
bool get maintainState => true;
@override
Color get barrierColor => Colors.black38;
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return FadeTransition(opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut), child: child);
}
@override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return builder(context);
}
}

View File

@@ -0,0 +1,136 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
import 'package:refilc/utils/format.dart';
class HeroScrollView extends StatefulWidget {
const HeroScrollView(
{super.key,
required this.child,
required this.title,
required this.icon,
this.italic = false,
this.navBarItems = const [],
this.onClose,
this.iconSize = 64.0,
this.scrollController});
final Widget child;
final String title;
final IconData? icon;
final List<Widget> navBarItems;
final VoidCallback? onClose;
final double iconSize;
final ScrollController? scrollController;
final bool italic;
@override
HeroScrollViewState createState() => HeroScrollViewState();
}
class HeroScrollViewState extends State<HeroScrollView> {
late ScrollController _scrollController;
bool showBarTitle = false;
@override
void initState() {
super.initState();
_scrollController = widget.scrollController ?? ScrollController();
_scrollController.addListener(() {
if (_scrollController.offset > 42.0) {
if (showBarTitle == false) setState(() => showBarTitle = true);
} else {
if (showBarTitle == true) setState(() => showBarTitle = false);
}
});
}
@override
void dispose() {
super.dispose();
_scrollController.dispose();
}
@override
Widget build(BuildContext context) {
return NestedScrollView(
controller: _scrollController,
physics:
const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
headerSliverBuilder: (context, _) => [
SliverAppBar(
pinned: true,
floating: false,
snap: false,
centerTitle: false,
titleSpacing: 0,
surfaceTintColor: Theme.of(context).scaffoldBackgroundColor,
title: AnimatedOpacity(
opacity: showBarTitle ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: Row(
children: [
Icon(widget.icon,
color: AppColors.of(context).text.withOpacity(.8)),
const SizedBox(width: 8.0),
Expanded(
child: Text(
widget.title.capital(),
overflow: TextOverflow.ellipsis,
maxLines: 2,
style: TextStyle(
color: AppColors.of(context).text,
fontWeight: FontWeight.w500,
fontStyle: widget.italic ? FontStyle.italic : null),
),
),
],
)),
leading: BackButton(
color: AppColors.of(context).text,
onPressed: () {
if (widget.onClose != null) {
widget.onClose!();
} else {
Navigator.of(context).pop();
}
}),
actions: widget.navBarItems,
expandedHeight: 145.69,
stretch: true,
flexibleSpace: FlexibleSpaceBar(
background: Stack(
children: [
Center(
child: Icon(
widget.icon,
size: widget.iconSize,
color: AppColors.of(context).text.withOpacity(.15),
),
),
Container(
alignment: Alignment.center,
margin: const EdgeInsets.only(top: 82),
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Text(
widget.title.capital(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 36.0,
color: AppColors.of(context).text.withOpacity(.9),
fontStyle: widget.italic ? FontStyle.italic : null,
fontWeight: FontWeight.bold),
),
),
],
),
),
),
],
body: widget.child,
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc/utils/color.dart';
import 'package:flutter/material.dart';
class MaterialActionButton extends StatelessWidget {
const MaterialActionButton({
super.key,
required this.child,
this.onPressed,
this.backgroundColor,
});
final Widget child;
final Function()? onPressed;
final Color? backgroundColor;
@override
Widget build(BuildContext context) {
return RawMaterialButton(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
shape: const StadiumBorder(),
fillColor: backgroundColor ?? AppColors.of(context).text.withOpacity(.15),
elevation: 0,
highlightElevation: 0,
onPressed: onPressed,
child: DefaultTextStyle(
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w600,
color: backgroundColor != null
? ColorUtils.foregroundColor(backgroundColor!)
: null,
),
child: child,
),
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
class NewContentIndicator extends StatelessWidget {
const NewContentIndicator({super.key, this.size = 64.0});
final double size;
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
alignment: Alignment.topRight,
width: size,
height: size,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: size / 3.0,
width: size / 3.0,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).scaffoldBackgroundColor,
width: size / 20.0),
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
decoration: BoxDecoration(
color: AppColors.of(context).red,
shape: BoxShape.circle,
),
),
),
);
}
}

View File

@@ -0,0 +1,160 @@
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class Panel extends StatelessWidget {
const Panel({
super.key,
this.child,
this.title,
this.padding,
this.hasShadow = true,
this.isTransparent = false,
});
final Widget? child;
final Widget? title;
final EdgeInsetsGeometry? padding;
final bool hasShadow;
final bool isTransparent;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Panel Title
if (title != null) PanelTitle(title: title!),
// Panel Body
if (child != null)
Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.0),
color: isTransparent
? Colors.transparent
: Theme.of(context).colorScheme.background,
boxShadow: [
if ((hasShadow && !isTransparent) &&
Provider.of<SettingsProvider>(context, listen: false)
.shadowEffect)
BoxShadow(
offset: const Offset(0, 21),
blurRadius: 23.0,
color: Theme.of(context).shadowColor,
)
],
),
padding: padding ?? const EdgeInsets.all(8.0),
child: child,
),
],
);
}
}
class PanelTitle extends StatelessWidget {
const PanelTitle({super.key, required this.title});
final Widget title;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 14.0, bottom: 8.0),
child: DefaultTextStyle(
style: Theme.of(context).textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.of(context).text.withOpacity(0.65)),
child: title,
),
);
}
}
class PanelHeader extends StatelessWidget {
const PanelHeader({super.key, required this.padding});
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: padding,
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16.0), topRight: Radius.circular(16.0)),
color: Theme.of(context).colorScheme.background,
boxShadow: [
if (Provider.of<SettingsProvider>(context, listen: false)
.shadowEffect)
BoxShadow(
offset: const Offset(0, 21),
blurRadius: 23.0,
color: Theme.of(context).shadowColor,
)
],
),
);
}
}
class PanelBody extends StatelessWidget {
const PanelBody({super.key, this.child, this.padding});
final Widget? child;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.background,
boxShadow: [
if (Provider.of<SettingsProvider>(context, listen: false)
.shadowEffect)
BoxShadow(
offset: const Offset(0, 21),
blurRadius: 23.0,
color: Theme.of(context).shadowColor,
)
],
),
padding: padding,
child: child,
);
}
}
class PanelFooter extends StatelessWidget {
const PanelFooter({super.key, required this.padding});
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: padding,
decoration: BoxDecoration(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(16.0),
bottomRight: Radius.circular(16.0)),
color: Theme.of(context).colorScheme.background,
boxShadow: [
if (Provider.of<SettingsProvider>(context, listen: false)
.shadowEffect)
BoxShadow(
offset: const Offset(0, 21),
blurRadius: 23.0,
color: Theme.of(context).shadowColor,
)
],
),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
class PanelActionButton extends StatelessWidget {
const PanelActionButton({
super.key,
this.onPressed,
this.padding = const EdgeInsets.symmetric(horizontal: 14.0),
this.leading,
this.title,
this.trailing,
});
final void Function()? onPressed;
final EdgeInsetsGeometry padding;
final Widget? leading;
final Widget? title;
final Widget? trailing;
@override
Widget build(BuildContext context) {
return RawMaterialButton(
onPressed: onPressed,
padding: padding,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0),
side: BorderSide(color: Theme.of(context).colorScheme.secondary.withOpacity(.6), width: 2),
),
child: ListTile(
leading: leading != null
? Theme(
data: Theme.of(context).copyWith(iconTheme: IconThemeData(color: Theme.of(context).colorScheme.secondary)),
child: leading!,
)
: null,
trailing: trailing,
title: title != null
? DefaultTextStyle(style: Theme.of(context).textTheme.titleMedium!.copyWith(fontWeight: FontWeight.w500, fontSize: 15.0), child: title!)
: null,
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
);
}
}

View File

@@ -0,0 +1,90 @@
import 'dart:ui';
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
class PanelButton extends StatelessWidget {
const PanelButton({
super.key,
this.onPressed,
this.padding = const EdgeInsets.symmetric(horizontal: 14.0),
this.leading,
this.title,
this.trailing,
this.background = false,
this.trailingDivider = false,
this.borderRadius,
this.longPressInstead = false,
});
final void Function()? onPressed;
final EdgeInsetsGeometry padding;
final Widget? leading;
final Widget? title;
final Widget? trailing;
final bool background;
final bool trailingDivider;
final BorderRadius? borderRadius;
final bool longPressInstead;
@override
Widget build(BuildContext context) {
final button = RawMaterialButton(
onPressed: !longPressInstead ? onPressed : null,
onLongPress: longPressInstead ? onPressed : null,
padding: padding,
shape: RoundedRectangleBorder(
borderRadius: borderRadius ?? BorderRadius.circular(12.0)),
fillColor: background
? Colors.white.withOpacity(
Theme.of(context).brightness == Brightness.light ? .35 : .2)
: null,
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: 16.0),
child: title!)
: null,
contentPadding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
);
if (!background) return button;
return BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 12.0,
sigmaY: 12.0,
),
child: button);
}
}

View File

@@ -0,0 +1,72 @@
import 'package:dotted_border/dotted_border.dart';
import 'package:refilc/models/settings.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class EmptyCard extends StatefulWidget {
const EmptyCard({
super.key,
required this.text,
});
final String text;
@override
State<EmptyCard> createState() => _EmptyCardState();
}
class _EmptyCardState extends State<EmptyCard> {
bool hold = false;
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onLongPressDown: (_) => setState(() => hold = true),
onLongPressEnd: (_) => setState(() => hold = false),
onLongPressCancel: () => setState(() => hold = false),
child: AnimatedScale(
scale: hold ? 1.018 : 1.0,
curve: Curves.easeInOutBack,
duration: const Duration(milliseconds: 300),
child: Container(
height: 444,
padding:
const EdgeInsets.only(top: 12, bottom: 12, left: 12, right: 12),
decoration: BoxDecoration(
color: const Color(0x280008FF),
borderRadius: const BorderRadius.all(Radius.circular(5)),
boxShadow: [
if (Provider.of<SettingsProvider>(context, listen: false)
.shadowEffect)
BoxShadow(
color: Colors.black.withOpacity(0.08),
offset: const Offset(0, 5),
blurRadius: 20,
spreadRadius: 10,
),
],
),
child: DottedBorder(
color: Colors.black.withOpacity(0.9),
dashPattern: const [12, 12],
padding:
const EdgeInsets.only(top: 20, bottom: 20, left: 20, right: 20),
child: Center(
child: Text(
widget.text,
style: TextStyle(
color: Colors.white.withOpacity(0.9),
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,375 @@
import 'package:dotted_border/dotted_border.dart';
import 'package:refilc/api/providers/user_provider.dart';
import 'package:refilc/helpers/average_helper.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/models/personality.dart';
import 'package:refilc_kreta_api/models/absence.dart';
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:refilc_kreta_api/models/lesson.dart';
import 'package:refilc_kreta_api/models/subject.dart';
import 'package:refilc_kreta_api/models/week.dart';
import 'package:refilc_kreta_api/providers/absence_provider.dart';
import 'package:refilc_kreta_api/providers/grade_provider.dart';
import 'package:refilc_kreta_api/providers/timetable_provider.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'personality_card.i18n.dart';
class PersonalityCard extends StatefulWidget {
const PersonalityCard({
super.key,
required this.user,
});
final UserProvider user;
@override
State<PersonalityCard> createState() => _PersonalityCardState();
}
class _PersonalityCardState extends State<PersonalityCard> {
late GradeProvider gradeProvider;
late AbsenceProvider absenceProvider;
late TimetableProvider timetableProvider;
late SettingsProvider settings;
late List<int> subjectAvgsList = [];
late Map<GradeSubject, double> subjectAvgs = {};
late double subjectAvg;
late List<Grade> classWorkGrades;
late Map<int, int> mostCommonGrade;
late List<Absence> absences = [];
final Map<GradeSubject, Lesson> _lessonCount = {};
late int totalDelays;
late int unexcusedAbsences;
late PersonalityType finalPersonality;
bool hold = false;
List<Grade> getSubjectGrades(GradeSubject 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();
@override
void initState() {
super.initState();
gradeProvider = Provider.of<GradeProvider>(context, listen: false);
absenceProvider = Provider.of<AbsenceProvider>(context, listen: false);
timetableProvider = Provider.of<TimetableProvider>(context, listen: false);
settings = Provider.of<SettingsProvider>(context, listen: false);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) async {
for (final lesson in timetableProvider.getWeek(Week.current()) ?? []) {
if (!lesson.isEmpty &&
lesson.subject.id != '' &&
lesson.lessonYearIndex != null) {
_lessonCount.update(
lesson.subject,
(value) {
if (lesson.lessonYearIndex! > value.lessonYearIndex!) {
return lesson;
} else {
return value;
}
},
ifAbsent: () => lesson,
);
}
}
setState(() {});
});
}
void getGrades() {
List<GradeSubject> subjects = gradeProvider.grades
.map((e) => e.subject)
.toSet()
.toList()
..sort((a, b) => a.name.compareTo(b.name));
for (GradeSubject subject in subjects) {
List<Grade> subjectGrades = getSubjectGrades(subject);
double avg = AverageHelper.averageEvals(subjectGrades);
if (avg != 0) subjectAvgs[subject] = avg;
subjectAvgsList.add(avg.round());
}
subjectAvg = subjectAvgs.isNotEmpty
? subjectAvgs.values.fold(0.0, (double a, double b) => a + b) /
subjectAvgs.length
: 0.0;
classWorkGrades =
gradeProvider.grades.where((a) => a.value.weight <= 75).toList();
}
void getMostCommonGrade() {
Map<int, int> counts = {};
subjectAvgsList.map((e) {
if (counts.containsKey(e)) {
counts.update(e, (value) => value++);
} else {
counts[e] = 1;
}
});
var maxValue = 0;
var maxKey = 0;
counts.forEach((k, v) {
if (v > maxValue) {
maxValue = v;
maxKey = k;
}
});
mostCommonGrade = {maxKey: maxValue};
}
void getAbsences() {
absences = absenceProvider.absences.where((a) => a.delay == 0).toList();
unexcusedAbsences = absences
.where((a) => a.state == Justification.unexcused && a.delay == 0)
.length;
}
void getAndSortDelays() {
Iterable<int> unexcusedDelays = absences
.where((a) => a.state == Justification.unexcused && a.delay > 0)
.map((e) => e.delay);
totalDelays = unexcusedDelays.isNotEmpty
? unexcusedDelays.reduce((a, b) => a + b)
: 0;
}
void doEverything() {
getGrades();
getMostCommonGrade();
getAbsences();
getAndSortDelays();
}
void getPersonality() {
if (settings.goodStudent) {
finalPersonality = PersonalityType.cheater;
} else if (subjectAvg > 4.7) {
finalPersonality = PersonalityType.geek;
} else if (mostCommonGrade.keys.toList()[0] == 1 &&
mostCommonGrade.values.toList()[0] > 1) {
finalPersonality = PersonalityType.fallible;
} else if (absences.length <= 12) {
finalPersonality = PersonalityType.healthy;
} else if (unexcusedAbsences >= 8) {
finalPersonality = PersonalityType.quitter;
} else if (totalDelays > 50) {
finalPersonality = PersonalityType.late;
} else if (absences.length >= 120) {
finalPersonality = PersonalityType.sick;
} else if (mostCommonGrade.keys.toList()[0] == 2) {
finalPersonality = PersonalityType.acceptable;
} else if (mostCommonGrade.keys.toList()[0] == 3) {
finalPersonality = PersonalityType.average;
} else if (classWorkGrades.length >= 5) {
finalPersonality = PersonalityType.diligent;
} else {
finalPersonality = PersonalityType.npc;
}
}
Widget cardInnerBuilder() {
Map<PersonalityType, Map<String, String>> personality = {
PersonalityType.geek: {
'emoji': '🤓',
'title': 't_geek',
'description': 'd_geek',
'subtitle': 's_geek',
'subvalue': subjectAvg.toStringAsFixed(2),
},
PersonalityType.sick: {
'emoji': '🤒',
'title': 't_sick',
'description': 'd_sick',
'subtitle': 's_sick',
'subvalue': absences.length.toString(),
},
PersonalityType.late: {
'emoji': '',
'title': 't_late',
'description': 'd_late',
'subtitle': 's_late',
'subvalue': totalDelays.toString(),
},
PersonalityType.quitter: {
'emoji': '',
'title': 't_quitter',
'description': 'd_quitter',
'subtitle': 's_quitter',
'subvalue': unexcusedAbsences.toString(),
},
PersonalityType.healthy: {
'emoji': '😷',
'title': 't_healthy',
'description': 'd_healthy',
'subtitle': 's_healthy',
'subvalue': absences.length.toString(),
},
PersonalityType.acceptable: {
'emoji': '🤏',
'title': 't_acceptable',
'description': 'd_acceptable',
'subtitle': 's_acceptable',
'subvalue': mostCommonGrade.values.toList()[0].toString(),
},
PersonalityType.fallible: {
'emoji': '📉',
'title': 't_fallible',
'description': 'd_fallible',
'subtitle': 's_fallible',
'subvalue': mostCommonGrade.values.toList()[0].toString(),
},
PersonalityType.average: {
'emoji': '👌',
'title': 't_average',
'description': 'd_average',
'subtitle': 's_average',
'subvalue': mostCommonGrade.values.toList()[0].toString(),
},
PersonalityType.diligent: {
'emoji': '💫',
'title': 't_diligent',
'description': 'd_diligent',
'subtitle': 's_diligent',
'subvalue': classWorkGrades.length.toString(),
},
PersonalityType.cheater: {
'emoji': '‍🧑‍💻',
'title': 't_cheater',
'description': 'd_cheater',
'subtitle': 's_cheater',
'subvalue': '0',
},
PersonalityType.npc: {
'emoji': '⛰️',
'title': 't_npc',
'description': 'd_npc',
'subtitle': 's_npc',
'subvalue': '69420',
}
};
Map<PersonalityType, Widget> personalityWidgets = {};
for (var i in personality.keys) {
Widget w = Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
personality[i]?['emoji'] ?? '',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 128.0,
height: 1.2,
),
),
Text(
(personality[i]?['title'] ?? 'unknown').i18n,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 38.0,
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 5),
Text(
(personality[i]?['description'] ?? 'unknown_personality').i18n,
textAlign: TextAlign.start,
style: TextStyle(
fontSize: 16,
height: 1.2,
color: Colors.white.withOpacity(0.8),
),
),
const SizedBox(height: 25),
Text(
(personality[i]?['subtitle'] ?? 'unknown').i18n,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 20.0,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
Text(
personality[i]?['subvalue'] ?? '0',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 69.0,
height: 1.15,
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
],
);
personalityWidgets.addAll({i: w});
}
return personalityWidgets[finalPersonality] ?? Container();
}
@override
Widget build(BuildContext context) {
doEverything();
getPersonality();
return GestureDetector(
onLongPressDown: (_) => setState(() => hold = true),
onLongPressEnd: (_) => setState(() => hold = false),
onLongPressCancel: () => setState(() => hold = false),
child: AnimatedScale(
scale: hold ? 1.018 : 1.0,
curve: Curves.easeInOutBack,
duration: const Duration(milliseconds: 300),
child: Container(
padding:
const EdgeInsets.only(top: 12, bottom: 12, left: 12, right: 12),
decoration: BoxDecoration(
color: const Color(0x280008FF),
borderRadius: const BorderRadius.all(Radius.circular(5)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
offset: const Offset(0, 5),
blurRadius: 20,
spreadRadius: 10,
),
],
),
child: DottedBorder(
color: Colors.black.withOpacity(0.9),
dashPattern: const [12, 12],
padding:
const EdgeInsets.only(top: 20, bottom: 20, left: 20, right: 20),
child: cardInnerBuilder(),
),
),
),
);
}
}

View File

@@ -0,0 +1,154 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
// main
"unknown": "???",
"unknown_personality": "Unknown personality...",
// personalities
"t_geek": "Know-It-All",
"d_geek":
"You learn a lot, but don't worry - Being a know-it-all is a blessing in disguise. You'll be successful in life.",
"s_geek": "Year-end average",
"t_sick": "Sick",
"d_sick":
"Get well soon, bro. Even if you lied about being sick to skip school.",
"s_sick": "Absences",
"t_late": "Late",
"d_late":
"The tram's wheel got punctured. The airplane was derailed. Your dog ate your shoe. We believe you.",
"s_late": "Delays (minutes)",
"t_quitter": "Skipper",
"d_quitter": "Supplementary exam incoming.",
"s_quitter": "Igazolatlan hiányzások",
"t_healthy": "Healthy",
"d_healthy":
"As cool as a cucumber! You almost never missed a class.",
"s_healthy": "Absences",
"t_acceptable": "Acceptable",
"d_acceptable":
"Final exams are D. But who cares? It's still a grade. Not a good one, but it's definitely a grade.",
"s_acceptable": "D's",
"t_fallible": "Failed",
"d_fallible": "Good luck next year.",
"s_fallible": "F's",
"t_average": "It's okay",
"d_average": "Not good, not bad. The golden mean, if you will...",
"s_average": "C's",
"t_diligent": "Hard-worker",
"d_diligent":
"You noted everything, you made that presentation, and you lead the group project.",
"s_diligent": "Class work A's",
"t_cheater": "Cheater",
"d_cheater":
"You enabled the \"Good Student\" mode. Wow. You may have outsmarted me, but I have outsmarted your outsmarting.",
"s_cheater": "Bitches",
"t_npc": "NPC",
"d_npc":
"You're such a non-player character, we couldn't give you a personality.",
"s_npc": "In-game playtime (hours)",
},
"hu_hu": {
// main
"unknown": "???",
"unknown_personality": "Ismeretlen személyiség...",
// personalities
"t_geek": "Stréber",
"d_geek":
"Sokat tanulsz, de ezzel semmi baj! Ez egyben áldás és átok, de legalább az életben sikeres leszel.",
"s_geek": "Év végi átlagod",
"t_sick": "Beteges",
"d_sick":
"Jobbulást, tesó. Még akkor is, ha hazudtál arról, hogy beteg vagy, hogy ne kelljen suliba menned.",
"s_sick": "Hiányzásaid",
"t_late": "Késős",
"d_late":
"Kilyukadt a villamos kereke. Kisiklott a repülő. A kutyád megette a cipőd. Elhisszük.",
"s_late": "Késések (perc)",
"t_quitter": "Lógós",
"d_quitter": "Osztályzóvizsga incoming.",
"s_quitter": "Igazolatlan hiányzások",
"t_healthy": "Makk",
"d_healthy":
"...egészséges vagy! Egész évben alig hiányoztál az iskolából.",
"s_healthy": "Hiányzásaid",
"t_acceptable": "Elmegy",
"d_acceptable":
"A kettes érettségi is érettségi. Nem egy jó érettségi, de biztos, hogy egy érettségi.",
"s_acceptable": "Kettesek",
"t_fallible": "Bukós",
"d_fallible": "Jövőre több sikerrel jársz.",
"s_fallible": "Karók",
"t_average": "Közepes",
"d_average": "Se jó, se rossz. Az arany középút, ha akarsz...",
"s_average": "Hármasok",
"t_diligent": "Szorgalmas",
"d_diligent":
"Leírtad a jegyzetet, megcsináltad a prezentációt, és te vezetted a projektmunkát.",
"s_diligent": "Órai munka ötösök",
"t_cheater": "Csaló",
"d_cheater":
"Bekapcsoltad a “Jó Tanuló” módot. Wow. Azt hitted, túl járhatsz az eszemen, de kijátszottam a kijátszásod.",
"s_cheater": "Bitches",
"t_npc": "NPC",
"d_npc":
"Egy akkora nagy non-player character vagy, hogy neked semmilyen személyiség nem jutott ezen kívül.",
"s_npc": "In-game playtime (óra)",
},
"de_de": {
// main
"unknown": "???",
"unknown_personality": "Unbekannte Persönlichkeit...",
// personalities
"t_geek": "Besserwisser",
"d_geek":
"Du lernst eine Menge, aber sorge dich nicht - ein Besserwisser zu sein wird sich letzten Endes doch als Segen erweisen. Du wirst erfolgreich sein im Leben.",
"s_geek": "Durchschnittsschüler",
"t_sick": "Krank",
"d_sick":
"Werd schnell wieder gesund, Brudi. Selbst wenn du gelogen hast, nur um Schule zu schwänzen zu können.",
"s_sick": "Abwesenheiten",
"t_late": "Verspätet",
"d_late":
"Die Straßenbahn hat eine Reifenpanne. Das Flugzeug ist entgleist. Dein Hund hat deinen Schuh gefressen. Klar, wir glauben dir.",
"s_late": "Verspätung (Minuten)",
"t_quitter": "Schulschwänzer",
"d_quitter": "Ein zusätzlicher Test wird anstehen.",
"s_quitter": "Unentschuldigte Abwesenheiten",
"t_healthy": "Gesund",
"d_healthy":
"Du bist die Ruhe selbst! Du hast fast nie eine Unterrichtsstunde verpasst.",
"s_healthy": "Abwesenheiten",
"t_acceptable": "Akzeptabel",
"d_acceptable":
"Die Abschlussprüfungen waren gerade einmal eine 4. Aber wen juckt's? Es ist immer noch positiv. Nicht allzu gut, aber definitiv positiv.",
"s_acceptable": "4er",
"t_fallible": "Durchgefallen",
"d_fallible": "Viel Glück im nächsten Jahr.",
"s_fallible": "5er",
"t_average": "Es ist in Ordnung",
"d_average":
"Nicht gut, nicht schlecht. Der goldene Durchschnitt, wenn du so willst...",
"s_average": "3er",
"t_diligent": "Ein Fleißiger",
"d_diligent":
"Du hast bei allem mitgeschrieben, du hast den Vortrag gehalten, und du hast die Gruppenarbeit geleitet.",
"s_diligent": "1er Schüler",
"t_cheater": "Geschummelt",
"d_cheater":
"Du hast den „Guter Schüler“ Modus aktiviert. Wow. Du magst mich zwar vielleicht überlistet haben, aber ich habe deine Überlistung überlistet.",
"s_cheater": "Bitches",
"t_npc": "COM",
"d_npc":
"Du bist einfach so sehr wie ein Computer, dass wir dir nicht einmal eine Persönlichkeit geben konnten.",
"s_npc": "Spielzeit (Stunden)",
}
};
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,100 @@
import 'package:refilc/api/providers/user_provider.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/models/user.dart';
import 'package:refilc_kreta_api/client/client.dart';
import 'package:refilc_kreta_api/providers/absence_provider.dart';
import 'package:refilc_kreta_api/providers/event_provider.dart';
import 'package:refilc_kreta_api/providers/exam_provider.dart';
import 'package:refilc_kreta_api/providers/grade_provider.dart';
import 'package:refilc_kreta_api/providers/homework_provider.dart';
import 'package:refilc_kreta_api/providers/message_provider.dart';
import 'package:refilc_kreta_api/providers/note_provider.dart';
import 'package:refilc_kreta_api/providers/timetable_provider.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_image.dart';
import 'package:refilc_mobile_ui/screens/settings/settings_screen.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:wtf_sliding_sheet/wtf_sliding_sheet.dart';
class ProfileButton extends StatelessWidget {
const ProfileButton({super.key, required this.child});
final ProfileImage child;
@override
Widget build(BuildContext context) {
final bool pMode =
Provider.of<SettingsProvider>(context, listen: false).presentationMode;
late UserProvider user;
late User? account;
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<MessageProvider>(context, listen: false)
.restoreRecipients(),
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(),
]);
user = Provider.of<UserProvider>(context);
try {
user.getUsers().forEach((acc) {
if (user.name!.toLowerCase().replaceAll(' ', '') !=
acc.name.toLowerCase().replaceAll(' ', '')) {
account = acc;
}
});
} catch (err) {
account = null;
}
return ProfileImage(
backgroundColor: !pMode
? child.backgroundColor
: Theme.of(context).colorScheme.secondary,
heroTag: child.heroTag,
key: child.key,
name: !pMode ? child.name : "János",
radius: child.radius,
badge: child.badge,
role: child.role,
profilePictureString: child.profilePictureString,
onTap: () {
showSlidingBottomSheet(
context,
useRootNavigator: true,
builder: (context) => SlidingSheetDialog(
color: Theme.of(context).scaffoldBackgroundColor,
duration: const Duration(milliseconds: 400),
scrollSpec: const ScrollSpec.bouncingScroll(),
snapSpec: const SnapSpec(
snap: true,
snappings: [1.0],
initialSnap: 1.0,
positioning: SnapPositioning.relativeToSheetHeight,
),
cornerRadius: 16,
cornerRadiusOnFullscreen: 0,
builder: (context, state) => Material(
color: Theme.of(context).scaffoldBackgroundColor,
child: const SettingsScreen(),
),
),
);
},
onLongPress: () {
if (account != null) {
user.setUser(account!.id);
restore().then((_) => user.setUser(account!.id));
}
},
);
}
}

View File

@@ -0,0 +1,259 @@
import 'dart:convert';
import 'package:refilc/models/user.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_mobile_ui/common/new_content_indicator.dart';
import 'package:flutter/material.dart';
import 'package:refilc/utils/color.dart';
class ProfileImage extends StatefulWidget {
const ProfileImage({
super.key,
this.onTap,
this.onDoubleTap,
this.onLongPress,
this.name,
this.backgroundColor,
this.radius = 20.0,
this.heroTag,
this.badge = false,
this.role = Role.student,
this.censored = false,
this.profilePictureString = "",
this.isNotePfp = false,
});
final void Function()? onTap;
final void Function()? onDoubleTap;
final void Function()? onLongPress;
final String? name;
final Color? backgroundColor;
final double radius;
final String? heroTag;
final bool badge;
final Role? role;
final bool censored;
final String profilePictureString;
final bool isNotePfp;
@override
State<ProfileImage> createState() => _ProfileImageState();
}
class _ProfileImageState extends State<ProfileImage> {
Image? profilePicture;
String? profPicSaved;
@override
void initState() {
super.initState();
updatePic();
}
void updatePic() {
profilePicture = widget.profilePictureString != ""
? Image.memory(
const Base64Decoder().convert(widget.profilePictureString),
fit: BoxFit.scaleDown,
gaplessPlayback: true)
: null;
profPicSaved = widget.profilePictureString;
}
@override
Widget build(BuildContext context) {
if (profPicSaved != widget.profilePictureString) updatePic();
if (widget.heroTag == null) {
return buildWithoutHero(context);
} else {
return buildWithHero(context);
}
}
Widget buildWithoutHero(BuildContext context) {
Color color = ColorUtils.foregroundColor(
widget.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: widget.name != null && widget.name! == 'Rendszerüzenet'
? widget.backgroundColor?.withOpacity(0.5) ??
AppColors.of(context).text.withOpacity(0.5)
: widget.backgroundColor ??
AppColors.of(context).text.withOpacity(.15),
child: InkWell(
onTap: widget.onTap,
onDoubleTap: widget.onDoubleTap,
onLongPress: widget.onLongPress,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: widget.radius * 2,
width: widget.radius * 2,
decoration: const BoxDecoration(
shape: BoxShape.circle,
),
child:
widget.name != null && (widget.name?.trim().length ?? 0) > 0
? Center(
child: widget.censored
? Container(
width: 15,
height: 15,
decoration: BoxDecoration(
color: color.withOpacity(.5),
borderRadius: BorderRadius.circular(8.0),
),
)
: profilePicture ??
Text(
(widget.name?.trim().length ?? 0) > 0
? (widget.name ?? "?").trim()[0]
: "?",
style: TextStyle(
color: color,
fontWeight: FontWeight.w600,
fontSize: (widget.isNotePfp ? 20 : 18.0) *
(widget.radius / 20.0),
),
),
)
: Container(),
),
),
),
// Role indicator
if (widget.role == Role.parent)
SizedBox(
height: widget.radius * 2,
width: widget.radius * 2,
child: Container(
alignment: Alignment.bottomRight,
child: Icon(Icons.shield,
color: roleColor, size: widget.radius / 1.3),
),
),
],
);
}
Widget buildWithHero(BuildContext context) {
Color color = ColorUtils.foregroundColor(
widget.backgroundColor ?? Theme.of(context).scaffoldBackgroundColor);
Color roleColor;
if (Theme.of(context).brightness == Brightness.light) {
roleColor = const Color(0xFF444444);
} else {
roleColor = const Color(0xFF555555);
}
Widget child = FittedBox(
fit: BoxFit.fitHeight,
child: Text(
(widget.name?.trim().length ?? 0) > 0
? (widget.name ?? "?").trim()[0]
: "?",
style: TextStyle(
color: color,
fontWeight: FontWeight.w600,
fontSize: 18.0 * (widget.radius / 20.0),
),
),
);
return SizedBox(
height: widget.radius * 2,
width: widget.radius * 2,
child: Stack(
alignment: Alignment.center,
children: [
if (widget.name != null && (widget.name?.trim().length ?? 0) > 0)
Hero(
tag: "${widget.heroTag!}background",
transitionOnUserGestures: true,
child: Material(
clipBehavior: Clip.hardEdge,
shape: const CircleBorder(),
color: profilePicture != null
? Colors.transparent
: widget.backgroundColor ??
AppColors.of(context).text.withOpacity(.15),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: widget.radius * 2,
width: widget.radius * 2,
decoration: const BoxDecoration(
shape: BoxShape.circle,
),
child: profilePicture,
),
),
),
Hero(
tag: "${widget.heroTag!}child",
transitionOnUserGestures: true,
child: Material(
clipBehavior: Clip.hardEdge,
shape: profilePicture != null ? const CircleBorder() : null,
type: MaterialType.transparency,
child: profilePicture ?? child,
),
),
// Badge
if (widget.badge)
Hero(
tag: "${widget.heroTag!}new_content_indicator",
child: NewContentIndicator(size: widget.radius * 2),
),
// Role indicator
if (widget.role == Role.parent)
Hero(
tag: "${widget.heroTag!}role_indicator",
child: FittedBox(
fit: BoxFit.fitHeight,
child: SizedBox(
height: widget.radius * 2,
width: widget.radius * 2,
child: Container(
alignment: Alignment.bottomRight,
child: Icon(Icons.shield,
color: roleColor, size: widget.radius / 1.3),
),
),
),
),
Material(
color: Colors.transparent,
clipBehavior: Clip.hardEdge,
shape: const CircleBorder(),
child: InkWell(
onTap: widget.onTap,
onDoubleTap: widget.onDoubleTap,
onLongPress: widget.onLongPress,
child: SizedBox(
height: widget.radius * 2,
width: widget.radius * 2,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
class ProgressBar extends StatelessWidget {
const ProgressBar(
{super.key, required this.value, this.backgroundColor, this.height = 8.0});
final double value;
final Color? backgroundColor;
final double height;
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Background
Container(
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.light
? Colors.black.withOpacity(0.1)
: Colors.white.withOpacity(0.1),
borderRadius: BorderRadius.circular(45.0),
),
width: double.infinity,
height: height,
),
// Slider
AnimatedContainer(
duration: const Duration(milliseconds: 500),
width: double.infinity,
child: CustomPaint(
painter: ProgressPainter(
backgroundColor:
backgroundColor ?? Theme.of(context).colorScheme.secondary,
height: height,
value: value.clamp(0, 1),
),
),
)
],
);
}
}
class ProgressPainter extends CustomPainter {
ProgressPainter(
{required this.height,
required this.value,
required this.backgroundColor});
final double height;
final double value;
final Color backgroundColor;
@override
void paint(Canvas canvas, Size size) {
double width = size.width * value;
if (width <= 0) return;
// Slider
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(0, 0, width, height),
const Radius.circular(45.0),
),
Paint()
..color = backgroundColor
..style = PaintingStyle.fill,
);
}
@override
bool shouldRepaint(ProgressPainter oldDelegate) {
return value != oldDelegate.value ||
height != oldDelegate.height ||
backgroundColor != oldDelegate.backgroundColor;
}
}

View File

@@ -0,0 +1,31 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
class RoundBorderIcon extends StatelessWidget {
final Color? color;
final double width;
final double padding;
final Widget icon;
const RoundBorderIcon(
{super.key,
this.color,
this.width = 1.5,
this.padding = 5.0,
required this.icon});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
border: Border.all(
color: color ?? AppColors.of(context).text, width: width),
borderRadius: BorderRadius.circular(50.0),
),
child: Padding(
padding: EdgeInsets.all(padding),
child: icon,
),
);
}
}

View File

@@ -0,0 +1,33 @@
import 'package:i18n_extension/i18n_extension.dart';
extension ScreensLocalization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"home": "Home",
"grades": "Grades",
"timetable": "Timetable",
"messages": "Messages",
"absences": "Absences",
},
"hu_hu": {
"home": "Kezdőlap",
"grades": "Jegyek",
"timetable": "Órarend",
"messages": "Üzenetek",
"absences": "Hiányok",
},
"de_de": {
"home": "Zuhause",
"grades": "Noten",
"timetable": "Zeitplan",
"messages": "Mitteilungen",
"absences": "Fehlen",
},
};
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,46 @@
import 'package:flutter/material.dart';
import 'package:wtf_sliding_sheet/wtf_sliding_sheet.dart' as ss;
void showSlidingBottomSheet(
{required Widget child, required BuildContext context}) =>
ss.showSlidingBottomSheet(context,
useRootNavigator: true,
builder: (context) => ss.SlidingSheetDialog(
cornerRadius: 16,
cornerRadiusOnFullscreen: 0,
avoidStatusBar: true,
color: Theme.of(context).colorScheme.background,
duration: const Duration(milliseconds: 400),
snapSpec: const ss.SnapSpec(
snap: true,
snappings: [0.5, 1.0],
positioning: ss.SnapPositioning.relativeToAvailableSpace,
),
headerBuilder: (context, state) {
return Material(
color: Theme.of(context).colorScheme.background,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
decoration: BoxDecoration(
color: Colors.grey,
borderRadius: BorderRadius.circular(12.0),
),
height: 4.0,
width: 60.0,
margin: const EdgeInsets.all(12.0),
),
],
),
);
},
builder: (context, state) {
return Material(
color: Theme.of(context).colorScheme.background,
child: Padding(
padding: const EdgeInsets.fromLTRB(12.0, 0, 12.0, 8.0),
child: child),
);
},
));

View File

@@ -0,0 +1,22 @@
import 'package:refilc_mobile_ui/common/action_button.dart';
import 'package:refilc_mobile_ui/common/soon_alert/soon_alert.i18n.dart';
import 'package:flutter/material.dart';
class SoonAlert extends StatelessWidget {
const SoonAlert({super.key});
static show({required BuildContext context}) =>
showDialog(context: context, builder: (context) => const SoonAlert());
@override
Widget build(BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12.0)),
title: Text('soon'.i18n),
content: Text('soon_body'.i18n),
actions: [
ActionButton(label: "Ok", onTap: () => Navigator.of(context).pop())
],
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"soon": "Soon...",
"soon_body": "This feature is currently not available yet!",
},
"hu_hu": {
"soon": "Hamarosan...",
"soon_body": "Ez a funkció jelenleg még nem elérhető!",
},
"de_de": {
"soon": "Bald...",
"soon_body": "Diese Funktion ist derzeit noch nicht verfügbar!",
},
};
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,56 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class SplittedMenuOption extends StatelessWidget {
const SplittedMenuOption({
super.key,
required this.text,
this.leading,
this.trailing,
this.padding,
this.onTap,
});
final String text;
final Widget? leading;
final Widget? trailing;
final EdgeInsetsGeometry? padding;
final Function()? onTap;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(borderRadius: BorderRadius.circular(2.0)),
child: InkWell(
splashColor: Colors.grey,
onLongPress: () {
if (kDebugMode) {
print('object');
}
},
onTap: onTap,
child: Padding(
padding: padding ?? EdgeInsets.zero,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
if (leading != null) leading!,
const SizedBox(
width: 16.0,
),
Text(text),
const SizedBox(
width: 16.0,
),
],
),
if (trailing != null) trailing!,
],
),
),
),
);
}
}

View File

@@ -0,0 +1,127 @@
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class SplittedPanel extends StatelessWidget {
const SplittedPanel({
super.key,
this.children,
this.title,
this.padding,
this.cardPadding,
this.hasShadow = true,
this.isSeparated = false,
this.spacing = 6.0,
this.isTransparent = false,
this.hasBorder = false,
});
final List<Widget>? children;
final Widget? title;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? cardPadding;
final bool hasShadow;
final bool isSeparated;
final double spacing;
final bool isTransparent;
final bool hasBorder;
@override
Widget build(BuildContext context) {
double sp = spacing;
if (isSeparated && spacing == 6.0) {
sp = 9.0;
}
List<Widget> childrenInMyBasement = [];
if (children != null) {
var i = 0;
for (var widget in children!) {
var w = Container(
width: double.infinity,
decoration: BoxDecoration(
color: isTransparent
? Colors.transparent
: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.vertical(
top: Radius.circular(i == 0 ? 16.0 : 8.0),
bottom: Radius.circular(children!.length == i + 1 ? 16.0 : 8.0),
),
border: hasBorder
? Border.all(
color:
Theme.of(context).colorScheme.primary.withOpacity(.25),
width: 1.0)
: null,
),
margin: EdgeInsets.only(top: i == 0 ? 0.0 : sp),
padding: cardPadding ?? EdgeInsets.zero,
child: widget,
);
childrenInMyBasement.add(w);
i++;
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// title
if (title != null)
SplittedPanelTitle(
title: title!,
leftPadding: (padding?.horizontal ?? 48.0) / 2,
),
// body
if (children != null)
Container(
width: double.infinity,
decoration: BoxDecoration(
color: Colors.transparent,
boxShadow: [
if (hasShadow &&
Provider.of<SettingsProvider>(context, listen: false)
.shadowEffect)
BoxShadow(
offset: const Offset(0, 21),
blurRadius: 23.0,
color: Theme.of(context).shadowColor,
)
],
),
padding: padding ??
const EdgeInsets.only(bottom: 20.0, left: 24.0, right: 24.0),
child: Column(children: childrenInMyBasement),
),
],
);
}
}
class SplittedPanelTitle extends StatelessWidget {
const SplittedPanelTitle(
{super.key, required this.title, this.leftPadding = 24.0});
final Widget title;
final double leftPadding;
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(left: 14.0 + leftPadding, bottom: 8.0),
child: DefaultTextStyle(
style: Theme.of(context).textTheme.titleMedium!.copyWith(
fontWeight: FontWeight.w600,
color: AppColors.of(context).text.withOpacity(0.65)),
child: title,
),
);
}
}

View File

@@ -0,0 +1,15 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void setSystemChrome(BuildContext context) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: [SystemUiOverlay.top, SystemUiOverlay.bottom]);
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Theme.of(context).brightness == Brightness.light ? Brightness.dark : Brightness.light,
systemNavigationBarColor: Theme.of(context).bottomNavigationBarTheme.backgroundColor,
systemNavigationBarIconBrightness: Theme.of(context).brightness == Brightness.light ? Brightness.dark : Brightness.light,
statusBarBrightness: Platform.isIOS ? Theme.of(context).brightness : null,
));
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:i18n_extension/i18n_widget.dart';
class TrendDisplay<T extends num> extends StatelessWidget {
const TrendDisplay({super.key, required this.current, required this.previous, this.padding});
final T current;
final T previous;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
const upIcon = "";
const downIcon = "";
final upColor = Colors.lightGreenAccent.shade700;
const downColor = Colors.redAccent;
Color color;
String icon;
double percentage;
if (previous > 0) {
percentage = (current - previous) * 100.0;
} else {
percentage = 0.0;
}
final String percentageText = percentage.abs().toStringAsFixed(1).replaceAll('.', I18n.of(context).locale.languageCode != 'en' ? ',' : '.');
if (!percentage.isNegative) {
color = upColor;
icon = upIcon;
} else {
color = downColor;
icon = downIcon;
}
if (percentage == 0) {
return const SizedBox();
}
return Padding(
padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0),
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 2.0),
child: Text(
icon,
style: TextStyle(fontSize: 18.0, color: color),
),
),
Text("$percentageText%", style: TextStyle(color: color)),
],
),
);
}
}

View File

@@ -0,0 +1,973 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'dart:ui' as ui;
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart' show kMinFlingVelocity;
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
double valueFromPercentageInRange({required final double min, max, percentage}) {
return percentage * (max - min) + min;
}
double percentageFromValueInRange({required final double min, max, value}) {
return (value - min) / (max - min);
}
const double _kOpenScale = 1.025;
const Color _borderColor = CupertinoDynamicColor.withBrightness(
color: Color(0xFFA9A9AF),
darkColor: Color(0xFF57585A),
);
typedef _DismissCallback = void Function(
BuildContext context,
double scale,
double opacity,
);
typedef ViewablePreviewBuilder = Widget Function(
BuildContext context,
Animation<double> animation,
Widget child,
);
typedef _ViewablePreviewBuilderChildless = Widget Function(
BuildContext context,
Animation<double> animation,
);
Rect _getRect(GlobalKey globalKey) {
assert(globalKey.currentContext != null);
final RenderBox renderBoxContainer = globalKey.currentContext!.findRenderObject()! as RenderBox;
final Offset containerOffset = renderBoxContainer.localToGlobal(
renderBoxContainer.paintBounds.topLeft,
);
return containerOffset & renderBoxContainer.paintBounds.size;
}
enum _ViewableLocation {
center,
left,
right,
}
class Viewable extends StatefulWidget {
const Viewable({
super.key,
required this.view,
required this.tile,
this.actions = const [],
this.previewBuilder,
});
final Widget tile;
final Widget view;
final List<Widget> actions;
final ViewablePreviewBuilder? previewBuilder;
@override
State<Viewable> createState() => _ViewableState();
}
class _ViewableState extends State<Viewable> with TickerProviderStateMixin {
final GlobalKey _childGlobalKey = GlobalKey();
bool _childHidden = false;
late AnimationController _openController;
Rect? _decoyChildEndRect;
OverlayEntry? _lastOverlayEntry;
_ViewableRoute<void>? _route;
@override
void initState() {
super.initState();
_openController = AnimationController(
duration: const Duration(milliseconds: 100),
vsync: this,
);
_openController.addStatusListener(_onDecoyAnimationStatusChange);
}
_ViewableLocation get _contextMenuLocation {
final Rect childRect = _getRect(_childGlobalKey);
final double screenWidth = MediaQuery.of(context).size.width;
final double center = screenWidth / 2;
final bool centerDividesChild = childRect.left < center && childRect.right > center;
final double distanceFromCenter = (center - childRect.center.dx).abs();
if (centerDividesChild && distanceFromCenter <= childRect.width / 4) {
return _ViewableLocation.center;
}
if (childRect.center.dx > center) {
return _ViewableLocation.right;
}
return _ViewableLocation.left;
}
void _openContextMenu() {
setState(() {
_childHidden = true;
});
_route = _ViewableRoute<void>(
actions: widget.actions,
barrierLabel: 'Dismiss',
filter: ui.ImageFilter.blur(
sigmaX: 5.0,
sigmaY: 5.0,
),
contextMenuLocation: _contextMenuLocation,
previousChildRect: _decoyChildEndRect!,
builder: (BuildContext context, Animation<double> animation) {
return ClipRRect(
borderRadius: BorderRadius.circular(16.0),
child: Material(
color: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.circular(16.0),
child: Stack(
children: [
Opacity(
opacity: animation.status == AnimationStatus.forward
? Curves.easeOutCirc.transform(animation.value)
: Curves.easeInCirc.transform(animation.value),
child: widget.view,
),
Opacity(
opacity: 1 -
(animation.status == AnimationStatus.forward
? Curves.easeOutCirc.transform(animation.value)
: Curves.easeInCirc.transform(animation.value)),
child: widget.tile,
),
],
),
),
);
},
);
Navigator.of(context, rootNavigator: true).push<void>(_route!);
_route!.animation!.addStatusListener(_routeAnimationStatusListener);
}
void _onDecoyAnimationStatusChange(AnimationStatus animationStatus) {
switch (animationStatus) {
case AnimationStatus.dismissed:
if (_route == null) {
setState(() {
_childHidden = false;
});
}
_lastOverlayEntry?.remove();
_lastOverlayEntry = null;
break;
case AnimationStatus.completed:
setState(() {
_childHidden = true;
});
_openContextMenu();
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
_lastOverlayEntry?.remove();
_lastOverlayEntry = null;
_openController.reset();
});
break;
case AnimationStatus.forward:
case AnimationStatus.reverse:
return;
}
}
void _routeAnimationStatusListener(AnimationStatus status) {
if (status != AnimationStatus.dismissed) {
return;
}
setState(() {
_childHidden = false;
});
_route!.animation!.removeStatusListener(_routeAnimationStatusListener);
_route = null;
}
void _onTap() {
_onTapDown(TapDownDetails(), anim: false);
}
void _onTapDown(TapDownDetails details, {anim = true}) {
setState(() {
_childHidden = true;
});
final Rect childRect = _getRect(_childGlobalKey);
_decoyChildEndRect = Rect.fromCenter(
center: childRect.center,
width: childRect.width * _kOpenScale,
height: childRect.height * _kOpenScale,
);
_lastOverlayEntry = OverlayEntry(
builder: (BuildContext context) {
return _DecoyChild(
beginRect: childRect,
controller: _openController,
endRect: _decoyChildEndRect,
child: widget.tile,
);
},
);
Overlay.of(context, rootOverlay: true).insert(_lastOverlayEntry!);
_openController.forward(from: anim ? 0.0 : 1.0);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _onTap,
child: TickerMode(
enabled: !_childHidden,
child: Opacity(
key: _childGlobalKey,
opacity: _childHidden ? 0.0 : 1.0,
child: widget.tile,
),
),
);
}
@override
void dispose() {
_openController.dispose();
super.dispose();
}
}
class _DecoyChild extends StatefulWidget {
const _DecoyChild({
this.beginRect,
required this.controller,
this.endRect,
this.child,
});
final Rect? beginRect;
final AnimationController controller;
final Rect? endRect;
final Widget? child;
@override
_DecoyChildState createState() => _DecoyChildState();
}
class _DecoyChildState extends State<_DecoyChild> with TickerProviderStateMixin {
static const Color _lightModeMaskColor = Color(0xFF888888);
static const Color _masklessColor = Color(0xFFFFFFFF);
final GlobalKey _childGlobalKey = GlobalKey();
late Animation<Color> _mask;
late Animation<Rect?> _rect;
@override
void initState() {
super.initState();
_mask = _OnOffAnimation<Color>(
controller: widget.controller,
onValue: _lightModeMaskColor,
offValue: _masklessColor,
intervalOn: 0.0,
intervalOff: 0.5,
);
final Rect midRect = widget.beginRect!.deflate(
widget.beginRect!.width * (_kOpenScale - 1.0) / 2,
);
_rect = TweenSequence<Rect?>(<TweenSequenceItem<Rect?>>[
TweenSequenceItem<Rect?>(
tween: RectTween(
begin: widget.beginRect,
end: midRect,
).chain(CurveTween(curve: Curves.easeInOutCubic)),
weight: 1.0,
),
TweenSequenceItem<Rect?>(
tween: RectTween(
begin: midRect,
end: widget.endRect,
).chain(CurveTween(curve: Curves.easeOutCubic)),
weight: 1.0,
),
]).animate(widget.controller);
_rect.addListener(_rectListener);
}
void _rectListener() {
if (widget.controller.value < 0.5) {
return;
}
HapticFeedback.selectionClick();
_rect.removeListener(_rectListener);
}
@override
void dispose() {
_rect.removeListener(_rectListener);
super.dispose();
}
Widget _buildAnimation(BuildContext context, Widget? child) {
final Color color = widget.controller.status == AnimationStatus.reverse ? _masklessColor : _mask.value;
return Positioned.fromRect(
rect: _rect.value!,
child: ShaderMask(
key: _childGlobalKey,
shaderCallback: (Rect bounds) {
return LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: <Color>[color, color],
).createShader(bounds);
},
child: widget.child,
),
);
}
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
AnimatedBuilder(
builder: _buildAnimation,
animation: widget.controller,
),
],
);
}
}
class _ViewableRoute<T> extends PopupRoute<T> {
_ViewableRoute({
required List<Widget> actions,
required _ViewableLocation contextMenuLocation,
this.barrierLabel,
_ViewablePreviewBuilderChildless? builder,
super.filter,
required Rect previousChildRect,
super.settings,
}) : _actions = actions,
_builder = builder,
_contextMenuLocation = contextMenuLocation,
_previousChildRect = previousChildRect;
static const Color _kModalBarrierColor = Color(0x6604040F);
static const Duration _kModalPopupTransitionDuration = Duration(milliseconds: 335);
final List<Widget> _actions;
final _ViewablePreviewBuilderChildless? _builder;
final GlobalKey _childGlobalKey = GlobalKey();
final _ViewableLocation _contextMenuLocation;
bool _externalOffstage = false;
bool _internalOffstage = false;
Orientation? _lastOrientation;
final Rect _previousChildRect;
double? _scale = 1.0;
final GlobalKey _sheetGlobalKey = GlobalKey();
static final CurveTween _curve = CurveTween(
curve: Curves.easeOutBack,
);
static final CurveTween _curveReverse = CurveTween(
curve: Curves.easeInBack,
);
static final RectTween _rectTween = RectTween();
static final Animatable<Rect?> _rectAnimatable = _rectTween.chain(_curve);
static final RectTween _rectTweenReverse = RectTween();
static final Animatable<Rect?> _rectAnimatableReverse = _rectTweenReverse.chain(
_curveReverse,
);
static final RectTween _sheetRectTween = RectTween();
final Animatable<Rect?> _sheetRectAnimatable = _sheetRectTween.chain(
_curve,
);
final Animatable<Rect?> _sheetRectAnimatableReverse = _sheetRectTween.chain(
_curveReverse,
);
static final Tween<double> _sheetScaleTween = Tween<double>();
static final Animatable<double> _sheetScaleAnimatable = _sheetScaleTween.chain(
_curve,
);
static final Animatable<double> _sheetScaleAnimatableReverse = _sheetScaleTween.chain(
_curveReverse,
);
final Tween<double> _opacityTween = Tween<double>(begin: 0.0, end: 1.0);
late Animation<double> _sheetOpacity;
@override
final String? barrierLabel;
@override
Color get barrierColor => _kModalBarrierColor;
@override
bool get barrierDismissible => true;
@override
bool get semanticsDismissible => false;
@override
Duration get transitionDuration => _kModalPopupTransitionDuration;
static Rect _getScaledRect(GlobalKey globalKey, double scale) {
final Rect childRect = _getRect(globalKey);
final Size sizeScaled = childRect.size * scale;
final Offset offsetScaled = Offset(
childRect.left + (childRect.size.width - sizeScaled.width) / 2,
childRect.top + (childRect.size.height - sizeScaled.height) / 2,
);
return offsetScaled & sizeScaled;
}
static AlignmentDirectional getSheetAlignment(_ViewableLocation contextMenuLocation) {
switch (contextMenuLocation) {
case _ViewableLocation.center:
return AlignmentDirectional.topCenter;
case _ViewableLocation.right:
return AlignmentDirectional.topEnd;
case _ViewableLocation.left:
return AlignmentDirectional.topStart;
}
}
static Rect _getSheetRectBegin(Orientation? orientation, _ViewableLocation contextMenuLocation, Rect childRect, Rect sheetRect) {
switch (contextMenuLocation) {
case _ViewableLocation.center:
final Offset target = orientation == Orientation.portrait ? childRect.bottomCenter : childRect.topCenter;
final Offset centered = target - Offset(sheetRect.width / 2, 0.0);
return centered & sheetRect.size;
case _ViewableLocation.right:
final Offset target = orientation == Orientation.portrait ? childRect.bottomRight : childRect.topRight;
return (target - Offset(sheetRect.width, 0.0)) & sheetRect.size;
case _ViewableLocation.left:
final Offset target = orientation == Orientation.portrait ? childRect.bottomLeft : childRect.topLeft;
return target & sheetRect.size;
}
}
void _onDismiss(BuildContext context, double scale, double opacity) {
_scale = scale;
_opacityTween.end = opacity;
_sheetOpacity = _opacityTween.animate(CurvedAnimation(
parent: animation!,
curve: const Interval(0.9, 1.0),
));
Navigator.of(context).pop();
}
void _updateTweenRects() {
final Rect childRect = _scale == null ? _getRect(_childGlobalKey) : _getScaledRect(_childGlobalKey, _scale!);
_rectTween.begin = _previousChildRect;
_rectTween.end = childRect;
final Rect childRectOriginal = Rect.fromCenter(
center: _previousChildRect.center,
width: _previousChildRect.width / _kOpenScale,
height: _previousChildRect.height / _kOpenScale,
);
final Rect sheetRect = _getRect(_sheetGlobalKey);
final Rect sheetRectBegin = _getSheetRectBegin(
_lastOrientation,
_contextMenuLocation,
childRectOriginal,
sheetRect,
);
_sheetRectTween.begin = sheetRectBegin;
_sheetRectTween.end = sheetRect;
_sheetScaleTween.begin = 0.0;
_sheetScaleTween.end = _scale;
_rectTweenReverse.begin = childRectOriginal;
_rectTweenReverse.end = childRect;
}
void _setOffstageInternally() {
super.offstage = _externalOffstage || _internalOffstage;
changedInternalState();
}
@override
bool didPop(T? result) {
_updateTweenRects();
return super.didPop(result);
}
@override
set offstage(bool value) {
_externalOffstage = value;
_setOffstageInternally();
}
@override
TickerFuture didPush() {
_internalOffstage = true;
_setOffstageInternally();
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
_updateTweenRects();
_internalOffstage = false;
_setOffstageInternally();
});
return super.didPush();
}
@override
Animation<double> createAnimation() {
final Animation<double> animation = super.createAnimation();
_sheetOpacity = _opacityTween.animate(CurvedAnimation(
parent: animation,
curve: Curves.linear,
));
return animation;
}
@override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return Container();
}
@override
Widget buildTransitions(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child) {
return OrientationBuilder(
builder: (BuildContext context, Orientation orientation) {
_lastOrientation = orientation;
if (!animation.isCompleted) {
final bool reverse = animation.status == AnimationStatus.reverse;
final Rect rect = reverse ? _rectAnimatableReverse.evaluate(animation)! : _rectAnimatable.evaluate(animation)!;
final Rect sheetRect = reverse ? _sheetRectAnimatableReverse.evaluate(animation)! : _sheetRectAnimatable.evaluate(animation)!;
final double sheetScale = reverse ? _sheetScaleAnimatableReverse.evaluate(animation) : _sheetScaleAnimatable.evaluate(animation);
return Stack(
children: <Widget>[
Positioned.fromRect(
rect: sheetRect,
child: FadeTransition(
opacity: _sheetOpacity,
child: Transform.scale(
alignment: getSheetAlignment(_contextMenuLocation),
scale: sheetScale,
child: _ViewableSheet(
key: _sheetGlobalKey,
actions: _actions,
),
),
),
),
Positioned.fromRect(
key: _childGlobalKey,
rect: rect,
child: _builder!(context, animation),
),
],
);
}
return _ContextMenuRouteStatic(
actions: _actions,
childGlobalKey: _childGlobalKey,
contextMenuLocation: _contextMenuLocation,
onDismiss: _onDismiss,
orientation: orientation,
sheetGlobalKey: _sheetGlobalKey,
child: _builder!(context, animation),
);
},
);
}
}
class _ContextMenuRouteStatic extends StatefulWidget {
const _ContextMenuRouteStatic({
this.actions,
required this.child,
this.childGlobalKey,
required this.contextMenuLocation,
this.onDismiss,
required this.orientation,
this.sheetGlobalKey,
});
final List<Widget>? actions;
final Widget child;
final GlobalKey? childGlobalKey;
final _ViewableLocation contextMenuLocation;
final _DismissCallback? onDismiss;
final Orientation orientation;
final GlobalKey? sheetGlobalKey;
@override
_ContextMenuRouteStaticState createState() => _ContextMenuRouteStaticState();
}
class _ContextMenuRouteStaticState extends State<_ContextMenuRouteStatic> with TickerProviderStateMixin {
static const double _kMinScale = 0.8;
static const double _kSheetScaleThreshold = 0.9;
static const double _kPadding = 20.0;
static const double _kDamping = 400.0;
static const Duration _kMoveControllerDuration = Duration(milliseconds: 600);
late Offset _dragOffset;
double _lastScale = 1.0;
late AnimationController _moveController;
late AnimationController _sheetController;
late Animation<Offset> _moveAnimation;
late Animation<double> _sheetScaleAnimation;
late Animation<double> _sheetOpacityAnimation;
static double _getScale(Orientation orientation, double maxDragDistance, double dy) {
final double dyDirectional = dy <= 0.0 ? dy : -dy;
return math.max(
_kMinScale,
(maxDragDistance + dyDirectional) / maxDragDistance,
);
}
void _onPanStart(DragStartDetails details) {
_moveController.value = 1.0;
_setDragOffset(Offset.zero);
}
void _onPanUpdate(DragUpdateDetails details) {
_setDragOffset(_dragOffset + details.delta);
}
void _onPanEnd(DragEndDetails details) {
if (details.velocity.pixelsPerSecond.dy.abs() >= kMinFlingVelocity) {
final bool flingIsAway = details.velocity.pixelsPerSecond.dy > 0;
final double finalPosition = flingIsAway ? _moveAnimation.value.dy + 100.0 : 0.0;
if (flingIsAway && _sheetController.status != AnimationStatus.forward) {
_sheetController.forward();
} else if (!flingIsAway && _sheetController.status != AnimationStatus.reverse) {
_sheetController.reverse();
}
_moveAnimation = Tween<Offset>(
begin: Offset(0.0, _moveAnimation.value.dy),
end: Offset(0.0, finalPosition),
).animate(_moveController);
_moveController.reset();
_moveController.duration = const Duration(
milliseconds: 64,
);
_moveController.forward();
_moveController.addStatusListener(_flingStatusListener);
return;
}
if (_lastScale == _kMinScale) {
widget.onDismiss!(context, _lastScale, _sheetOpacityAnimation.value);
return;
}
_moveController.addListener(_moveListener);
_moveController.reverse();
}
void _moveListener() {
if (_lastScale > _kSheetScaleThreshold) {
_moveController.removeListener(_moveListener);
if (_sheetController.status != AnimationStatus.dismissed) {
_sheetController.reverse();
}
}
}
void _flingStatusListener(AnimationStatus status) {
if (status != AnimationStatus.completed) {
return;
}
_moveController.duration = _kMoveControllerDuration;
_moveController.removeStatusListener(_flingStatusListener);
if (_moveAnimation.value.dy == 0.0) {
return;
}
widget.onDismiss!(context, _lastScale, _sheetOpacityAnimation.value);
}
Alignment _getChildAlignment(Orientation orientation, _ViewableLocation contextMenuLocation) {
switch (contextMenuLocation) {
case _ViewableLocation.center:
return orientation == Orientation.portrait ? Alignment.bottomCenter : Alignment.topRight;
case _ViewableLocation.right:
return orientation == Orientation.portrait ? Alignment.bottomCenter : Alignment.topLeft;
case _ViewableLocation.left:
return orientation == Orientation.portrait ? Alignment.bottomCenter : Alignment.topRight;
}
}
void _setDragOffset(Offset dragOffset) {
final double endX = _kPadding * dragOffset.dx / _kDamping;
final double endY = dragOffset.dy >= 0.0 ? dragOffset.dy : _kPadding * dragOffset.dy / _kDamping;
setState(() {
_dragOffset = dragOffset;
_moveAnimation = Tween<Offset>(
begin: Offset.zero,
end: Offset(
endX.clamp(-_kPadding, _kPadding),
endY,
),
).animate(
CurvedAnimation(
parent: _moveController,
curve: Curves.elasticIn,
),
);
if (_lastScale <= _kSheetScaleThreshold && _sheetController.status != AnimationStatus.forward && _sheetScaleAnimation.value != 0.0) {
_sheetController.forward();
} else if (_lastScale > _kSheetScaleThreshold && _sheetController.status != AnimationStatus.reverse && _sheetScaleAnimation.value != 1.0) {
_sheetController.reverse();
}
});
}
List<Widget> _getChildren(Orientation orientation, _ViewableLocation contextMenuLocation) {
final Expanded child = Expanded(
child: Align(
alignment: _getChildAlignment(
widget.orientation,
widget.contextMenuLocation,
),
child: AnimatedBuilder(
animation: _moveController,
builder: _buildChildAnimation,
child: widget.child,
),
),
);
const SizedBox spacer = SizedBox(
width: _kPadding,
height: _kPadding,
);
final sheet = AnimatedBuilder(
animation: _sheetController,
builder: _buildSheetAnimation,
child: _ViewableSheet(
key: widget.sheetGlobalKey,
actions: widget.actions!,
),
);
switch (contextMenuLocation) {
case _ViewableLocation.center:
return <Widget>[child, spacer, sheet];
case _ViewableLocation.right:
return orientation == Orientation.portrait ? <Widget>[child, spacer, sheet] : <Widget>[sheet, spacer, child];
case _ViewableLocation.left:
return <Widget>[child, spacer, sheet];
}
}
Widget _buildSheetAnimation(BuildContext context, Widget? child) {
return Transform.scale(
alignment: _ViewableRoute.getSheetAlignment(widget.contextMenuLocation),
scale: _sheetScaleAnimation.value,
child: FadeTransition(
opacity: _sheetOpacityAnimation,
child: child,
),
);
}
Widget _buildChildAnimation(BuildContext context, Widget? child) {
_lastScale = _getScale(
widget.orientation,
MediaQuery.of(context).size.height,
_moveAnimation.value.dy,
);
return Transform.scale(
key: widget.childGlobalKey,
scale: _lastScale,
child: child,
);
}
Widget _buildAnimation(BuildContext context, Widget? child) {
return Transform.translate(
offset: _moveAnimation.value,
child: child,
);
}
@override
void initState() {
super.initState();
_moveController = AnimationController(
duration: _kMoveControllerDuration,
value: 1.0,
vsync: this,
);
_sheetController = AnimationController(
duration: const Duration(milliseconds: 100),
reverseDuration: const Duration(milliseconds: 200),
vsync: this,
);
_sheetScaleAnimation = Tween<double>(
begin: 1.0,
end: 0.0,
).animate(
CurvedAnimation(
parent: _sheetController,
curve: Curves.linear,
reverseCurve: Curves.easeInBack,
),
);
_sheetOpacityAnimation = Tween<double>(
begin: 1.0,
end: 0.0,
).animate(_sheetController);
_setDragOffset(Offset.zero);
}
@override
void dispose() {
_moveController.dispose();
_sheetController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final List<Widget> children = _getChildren(
widget.orientation,
widget.contextMenuLocation,
);
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(_kPadding),
child: Align(
alignment: Alignment.topLeft,
child: GestureDetector(
onPanEnd: _onPanEnd,
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
child: AnimatedBuilder(
animation: _moveController,
builder: _buildAnimation,
child: widget.orientation == Orientation.portrait
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
),
),
),
),
),
);
}
}
class _ViewableSheet extends StatelessWidget {
const _ViewableSheet({
super.key,
required this.actions,
});
final List<Widget> actions;
List<Widget> getChildren(BuildContext context) {
if (actions.isEmpty) return [];
final Widget menu = Expanded(
child: IntrinsicHeight(
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(13.0)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
actions.first,
for (Widget action in actions.skip(1))
DecoratedBox(
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: CupertinoDynamicColor.resolve(_borderColor, context),
width: 0.5,
)),
),
position: DecorationPosition.foreground,
child: action,
),
],
),
),
),
);
return [menu];
}
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: getChildren(context),
);
}
}
class _OnOffAnimation<T> extends CompoundAnimation<T> {
_OnOffAnimation({
required AnimationController controller,
required T onValue,
required T offValue,
required double intervalOn,
required double intervalOff,
}) : _offValue = offValue,
assert(intervalOn >= 0.0 && intervalOn <= 1.0),
assert(intervalOff >= 0.0 && intervalOff <= 1.0),
assert(intervalOn <= intervalOff),
super(
first: Tween<T>(begin: offValue, end: onValue).animate(
CurvedAnimation(
parent: controller,
curve: Interval(intervalOn, intervalOn),
),
),
next: Tween<T>(begin: onValue, end: offValue).animate(
CurvedAnimation(
parent: controller,
curve: Interval(intervalOff, intervalOff),
),
),
);
final T _offValue;
@override
T get value => next.value == _offValue ? next.value : first.value;
}

View File

@@ -0,0 +1,56 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
class AbsenceDisplay extends StatelessWidget {
const AbsenceDisplay(this.excused, this.unexcused, this.pending, {super.key});
final int excused;
final int unexcused;
final int pending;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.only(top: 5.0),
// padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 6.0),
// decoration: BoxDecoration(
// color: Theme.of(context).scaffoldBackgroundColor.withOpacity(.2),
// borderRadius: BorderRadius.circular(12.0),
// ),
child: Row(children: [
if (excused > 0)
Icon(
FeatherIcons.check,
size: 16.0,
color: AppColors.of(context).green,
),
if (excused > 0) const SizedBox(width: 2.0),
if (excused > 0)
Text(excused.toString(),
style: const TextStyle(fontFamily: "monospace", fontSize: 14.0)),
if (excused > 0 && pending > 0) const SizedBox(width: 6.0),
if (pending > 0)
Icon(
FeatherIcons.slash,
size: 14.0,
color: AppColors.of(context).orange,
),
if (pending > 0) const SizedBox(width: 3.0),
if (pending > 0)
Text(pending.toString(),
style: const TextStyle(fontFamily: "monospace", fontSize: 14.0)),
if (unexcused > 0 && pending > 0) const SizedBox(width: 3.0),
if (unexcused > 0)
Icon(
FeatherIcons.x,
size: 18.0,
color: AppColors.of(context).red,
),
if (unexcused > 0)
Text(unexcused.toString(),
style: const TextStyle(fontFamily: "monospace", fontSize: 14.0)),
]),
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:refilc/helpers/subject.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc/utils/format.dart';
import 'package:refilc_kreta_api/models/subject.dart';
import 'package:refilc_mobile_ui/common/widgets/absence/absence_display.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class AbsenceSubjectTile extends StatelessWidget {
const AbsenceSubjectTile(this.subject,
{super.key,
this.percentage = 0.0,
this.excused = 0,
this.unexcused = 0,
this.pending = 0,
this.onTap});
final GradeSubject subject;
final void Function()? onTap;
final double percentage;
final int excused;
final int unexcused;
final int pending;
@override
Widget build(BuildContext context) {
SettingsProvider settingsProvider = Provider.of<SettingsProvider>(context);
return Material(
type: MaterialType.transparency,
child: ListTile(
// minLeadingWidth: 32.0,
dense: true,
contentPadding: const EdgeInsets.only(left: 8.0, right: 6.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
visualDensity: VisualDensity.compact,
onTap: onTap,
leading: Icon(
SubjectIcon.resolveVariant(subject: subject, context: context),
size: 32.0),
title: Text(
subject.renamedTo ?? subject.name.capital(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15.0,
fontStyle:
subject.isRenamed && settingsProvider.renamedSubjectsItalics
? FontStyle.italic
: null),
),
subtitle: AbsenceDisplay(excused, unexcused, pending),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 8.0),
if (percentage >= 0)
Stack(
alignment: Alignment.centerRight,
children: [
const Opacity(
opacity: 0,
child: Text("100%",
style: TextStyle(fontFamily: "monospace"))),
Text(
"${percentage.round()}%",
style: TextStyle(
// fontFamily: "monospace",
color: getColorByPercentage(percentage, context: context),
fontWeight: FontWeight.w700,
fontSize: 24.0,
),
),
],
),
],
),
),
);
}
}
Color getColorByPercentage(double percentage, {required BuildContext context}) {
Color color = AppColors.of(context).text;
percentage = percentage.round().toDouble();
if (percentage > 35) {
color = AppColors.of(context).red;
} else if (percentage > 25) {
color = AppColors.of(context).orange;
} else if (percentage > 15) {
color = AppColors.of(context).yellow;
}
return color.withOpacity(.8);
}

View File

@@ -0,0 +1,151 @@
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc/utils/format.dart';
import 'package:refilc_kreta_api/models/absence.dart';
import 'package:refilc_mobile_ui/common/widgets/absence_group/absence_group_container.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:provider/provider.dart';
import 'absence_tile.i18n.dart';
class AbsenceTile extends StatelessWidget {
const AbsenceTile(this.absence,
{super.key, this.onTap, this.elevation = 0.0, this.padding});
final Absence absence;
final void Function()? onTap;
final double elevation;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
Color color = justificationColor(absence.state, context: context);
bool group = AbsenceGroupContainer.of(context) != null;
SettingsProvider settingsProvider = Provider.of<SettingsProvider>(context);
return Container(
decoration: BoxDecoration(
boxShadow: [
if (elevation > 0)
BoxShadow(
offset: Offset(0, 21 * elevation),
blurRadius: 23.0 * elevation,
color: Theme.of(context).shadowColor,
)
],
borderRadius: BorderRadius.circular(14.0),
),
child: Material(
type: MaterialType.transparency,
child: Padding(
// padding: padding ??
// (group
// ? EdgeInsets.zero
// : const EdgeInsets.symmetric(
// horizontal: 0.0,
// )),
padding: padding ?? EdgeInsets.zero,
child: ListTile(
onTap: onTap,
visualDensity: VisualDensity.compact,
dense: group,
contentPadding: const EdgeInsets.only(
left: 15.5, right: 12.0, top: 2.0, bottom: 2.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(!group ? 14.0 : 12.0)),
leading: Container(
width: 39,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: !group ? color.withOpacity(.25) : null,
),
child: Center(
child: Icon(justificationIcon(absence.state), color: color)),
),
title: !group
? Text.rich(TextSpan(
text: "${absence.delay == 0 ? "" : absence.delay}",
style: const TextStyle(
fontWeight: FontWeight.w700, fontSize: 15.5),
children: [
TextSpan(
text: absence.delay == 0
? justificationName(absence.state)
.fill(["absence".i18n]).capital()
: 'minute'.plural(absence.delay) +
justificationName(absence.state)
.fill(["delay".i18n]),
style: const TextStyle(fontWeight: FontWeight.w600),
),
],
))
: Text(
(absence.lessonIndex != null
? "${absence.lessonIndex}. "
: "") +
(absence.subject.renamedTo ??
absence.subject.name.capital()),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w500,
fontSize: 14.0,
fontStyle: absence.subject.isRenamed &&
settingsProvider.renamedSubjectsItalics
? FontStyle.italic
: null),
),
subtitle: !group
? Text(
absence.subject.renamedTo ?? absence.subject.name.capital(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
// DateFormat("MM. dd. (EEEEE)", I18n.of(context).locale.toString()).format(absence.date),
style: TextStyle(
fontWeight: FontWeight.w500,
fontStyle: absence.subject.isRenamed &&
settingsProvider.renamedSubjectsItalics
? FontStyle.italic
: null),
)
: null,
),
),
),
);
}
static String justificationName(Justification state) {
switch (state) {
case Justification.excused:
return "excused".i18n;
case Justification.pending:
return "pending".i18n;
case Justification.unexcused:
return "unexcused".i18n;
}
}
static Color justificationColor(Justification state,
{required BuildContext context}) {
switch (state) {
case Justification.excused:
return AppColors.of(context).green;
case Justification.pending:
return AppColors.of(context).orange;
case Justification.unexcused:
return AppColors.of(context).red;
}
}
static IconData justificationIcon(Justification state) {
switch (state) {
case Justification.excused:
return FeatherIcons.check;
case Justification.pending:
return FeatherIcons.slash;
case Justification.unexcused:
return FeatherIcons.x;
}
}
}

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": {
"excused": "excused %s",
"pending": "%s to be excused",
"unexcused": "unexcused %s",
"absence": "absence",
"delay": "delay",
"minute": " minutes of ".one(" minute of "),
},
"hu_hu": {
"excused": "igazolt %s",
"pending": "igazolandó %s",
"unexcused": "igazolatlan %s",
"absence": "hiányzás",
"delay": "késés",
"minute": " perc ",
},
"de_de": {
"excused": "anerkannt %s",
"pending": "%s zu anerkennen",
"unexcused": "unanerkannt %s",
"absence": "Abwesenheit",
"delay": "Verspätung",
"minute": " Minuten ".one(" Minute "),
}
};
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,155 @@
// ignore_for_file: empty_catches
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/absence.dart';
import 'package:refilc_mobile_ui/common/bottom_card.dart';
import 'package:refilc_mobile_ui/common/custom_snack_bar.dart';
import 'package:refilc_mobile_ui/common/detail.dart';
import 'package:refilc_mobile_ui/common/panel/panel_action_button.dart';
import 'package:refilc_mobile_ui/common/widgets/absence/absence_tile.dart';
import 'package:refilc/utils/format.dart';
import 'package:refilc_mobile_ui/pages/timetable/timetable_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:refilc/utils/reverse_search.dart';
import 'package:provider/provider.dart';
import 'absence_view.i18n.dart';
class AbsenceView extends StatelessWidget {
const AbsenceView(this.absence,
{super.key, this.outsideContext, this.viewable = false});
final Absence absence;
final BuildContext? outsideContext;
final bool viewable;
static show(Absence absence, {required BuildContext context}) {
showBottomCard(
context: context, child: AbsenceView(absence, outsideContext: context));
}
@override
Widget build(BuildContext context) {
Color color =
AbsenceTile.justificationColor(absence.state, context: context);
SettingsProvider settingsProvider = Provider.of<SettingsProvider>(context);
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.only(left: 16.0, right: 12.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8.0)),
leading: Container(
width: 44.0,
height: 44.0,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color.withOpacity(.25),
),
child: Center(
child: Icon(
AbsenceTile.justificationIcon(absence.state),
color: color,
),
),
),
title: Text(
absence.subject.renamedTo ?? absence.subject.name.capital(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w700,
fontStyle: absence.subject.isRenamed &&
settingsProvider.renamedSubjectsItalics
? FontStyle.italic
: null),
),
subtitle: Text(
(absence.teacher.isRenamed
? absence.teacher.renamedTo
: absence.teacher.name) ??
'',
// DateFormat("MM. dd. (EEEEE)", I18n.of(context).locale.toString()).format(absence.date),
style: const TextStyle(fontWeight: FontWeight.w500),
),
trailing: Text(
absence.date.format(context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
// Absence Details
if (absence.delay > 0)
Detail(
title: "delay".i18n,
description:
"${absence.delay} ${"minutes".i18n.plural(absence.delay)}",
),
if (absence.lessonIndex != null)
Detail(
title: "Lesson".i18n,
description:
"${absence.lessonIndex}. (${absence.lessonStart.format(context, timeOnly: true)}"
" - "
"${absence.lessonEnd.format(context, timeOnly: true)})",
),
if (absence.justification != null)
Detail(
title: "Excuse".i18n,
description: absence.justification?.description ?? "",
),
if (absence.mode != null)
Detail(
title: "Mode".i18n,
description: absence.mode?.description ?? ""),
Detail(
title: "Submit date".i18n,
description: absence.submitDate.format(context)),
// Show in timetable
if (!viewable)
Padding(
padding: const EdgeInsets.only(
left: 16.0, right: 16.0, bottom: 6.0, top: 12.0),
child: PanelActionButton(
leading: const Icon(FeatherIcons.calendar),
title: Text(
"show in timetable".i18n,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onPressed: () {
// https://discord.com/channels/1111649116020285532/1149964760130002945
Navigator.of(context).pop();
if (outsideContext != null) {
ReverseSearch.getLessonByAbsence(absence, context)
.then((lesson) {
if (lesson != null) {
TimetablePage.jump(outsideContext!, lesson: lesson);
} else {
ScaffoldMessenger.of(context)
.showSnackBar(CustomSnackBar(
content: Text("Cannot find lesson".i18n,
style: const TextStyle(color: Colors.white)),
backgroundColor: AppColors.of(context).red,
context: context,
));
}
});
}
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"Lesson": "Lesson",
"Excuse": "Excuse",
"Mode": "Mode",
"Submit date": "Submit Date",
"show in timetable": "Show in timetable",
"minutes": "minutes".one("minute"),
"delay": "Delay",
},
"hu_hu": {
"Lesson": "Óra",
"Excuse": "Igazolás",
"Mode": "Típus",
"Submit date": "Rögzítés dátuma",
"show in timetable": "Megtekintés az órarendben",
"minutes": "perc",
"delay": "Késés",
},
"de_de": {
"Lesson": "Stunde",
"Excuse": "Anerkannt",
"Mode": "Typ",
"Submit date": "Datum einreichen",
"show in timetable": "im Stundenplan anzeigen",
"minutes": "Minuten".one("Minute"),
"delay": "Verspätung",
}
};
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,70 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/absence.dart';
import 'package:refilc_mobile_ui/common/custom_snack_bar.dart';
import 'package:refilc_mobile_ui/common/panel/panel_button.dart';
import 'package:refilc_mobile_ui/common/viewable.dart';
import 'package:refilc_mobile_ui/common/widgets/absence/absence_tile.dart';
import 'package:refilc_mobile_ui/common/widgets/absence/absence_view.dart';
import 'package:refilc_mobile_ui/common/widgets/absence_group/absence_group_container.dart';
import 'package:refilc_mobile_ui/common/widgets/card_handle.dart';
import 'package:refilc_mobile_ui/pages/absences/absence_subject_view_container.dart';
import 'package:refilc_mobile_ui/pages/timetable/timetable_page.dart';
import 'package:flutter/material.dart';
import 'package:refilc/utils/reverse_search.dart';
import 'absence_view.i18n.dart';
class AbsenceViewable extends StatelessWidget {
const AbsenceViewable(this.absence, {super.key, this.padding});
final Absence absence;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
final subject = AbsenceSubjectViewContainer.of(context) != null;
final group = AbsenceGroupContainer.of(context) != null;
final tile = AbsenceTile(absence, padding: padding);
return Viewable(
tile: group ? AbsenceGroupContainer(child: tile) : tile,
view: CardHandle(child: AbsenceView(absence, viewable: true)),
actions: [
PanelButton(
background: true,
title: Text(
"show in timetable".i18n,
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
onPressed: () {
Navigator.of(context, rootNavigator: true).pop();
if (subject) {
Future.delayed(const Duration(milliseconds: 250)).then((_) {
Navigator.of(context, rootNavigator: true).pop(absence);
});
} else {
Future.delayed(const Duration(milliseconds: 250)).then((_) {
ReverseSearch.getLessonByAbsence(absence, context)
.then((lesson) {
if (lesson != null) {
TimetablePage.jump(context, lesson: lesson);
} else {
ScaffoldMessenger.of(context).showSnackBar(CustomSnackBar(
content: Text("Cannot find lesson".i18n,
style: const TextStyle(color: Colors.white)),
backgroundColor: AppColors.of(context).red,
context: context,
));
}
});
});
}
},
),
],
);
}
}

View File

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

View File

@@ -0,0 +1,103 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/absence.dart';
import 'package:refilc_mobile_ui/common/widgets/absence/absence_viewable.dart';
import 'package:refilc_mobile_ui/common/widgets/absence_group/absence_group_container.dart';
import 'package:refilc_mobile_ui/common/widgets/absence/absence_tile.dart';
import 'package:refilc/utils/format.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:rounded_expansion_tile/rounded_expansion_tile.dart';
import 'absence_group_tile.i18n.dart';
class AbsenceGroupTile extends StatelessWidget {
const AbsenceGroupTile(this.absences,
{super.key, this.showDate = false, this.padding});
final List<AbsenceViewable> absences;
final bool showDate;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
Justification state =
getState(absences.map((e) => e.absence.state).toList());
Color color = AbsenceTile.justificationColor(state, context: context);
absences.sort((a, b) =>
a.absence.lessonIndex?.compareTo(b.absence.lessonIndex ?? 0) ?? -1);
return ClipRRect(
borderRadius: BorderRadius.circular(14.0),
child: Material(
type: MaterialType.transparency,
child: Padding(
padding: padding ?? EdgeInsets.zero,
child: AbsenceGroupContainer(
child: RoundedExpansionTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
childrenPadding: EdgeInsets.zero,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 0),
tileColor: Colors.transparent,
duration: const Duration(milliseconds: 250),
trailingDuration: 0.5,
trailing: const Icon(FeatherIcons.chevronDown),
leading: Container(
width: 39.0,
height: 39.0,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color.withOpacity(.25),
),
child: Center(
child: Icon(AbsenceTile.justificationIcon(state),
color: color)),
),
title: Text.rich(TextSpan(
text:
"${absences.where((a) => a.absence.state == state).length} ",
style: TextStyle(
fontWeight: FontWeight.w700,
color: AppColors.of(context).text),
children: [
TextSpan(
text: AbsenceTile.justificationName(state)
.fill(["absence".i18n]),
style: TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.of(context).text),
),
],
)),
subtitle: showDate
? Text(
absences.first.absence.date
.format(context, weekday: true),
style: TextStyle(
fontWeight: FontWeight.w500,
color: AppColors.of(context).text.withOpacity(0.8)),
)
: null,
children: absences,
),
),
),
),
);
}
static Justification getState(List<Justification> states) {
Justification state;
if (states.any((element) => element == Justification.unexcused)) {
state = Justification.unexcused;
} else if (states.any((element) => element == Justification.pending)) {
state = Justification.pending;
} else {
state = Justification.excused;
}
return state;
}
}

View File

@@ -0,0 +1,21 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"absence": "absences",
},
"hu_hu": {
"absence": "hiányzás",
},
"de_de": {
"absence": "Fehlen",
}
};
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,53 @@
import 'package:refilc/models/ad.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
import 'package:refilc_mobile_ui/common/panel/panel_button.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
class AdTile extends StatelessWidget {
const AdTile(this.ad, {super.key, this.onTap, this.padding});
final Ad ad;
final Function()? onTap;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0),
child: PanelButton(
padding: const EdgeInsets.only(left: 8.0, right: 16.0),
onPressed: onTap,
title: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ad.title,
),
Text(
ad.description,
style: TextStyle(
fontWeight: FontWeight.w500,
color: AppColors.of(context).text.withOpacity(0.7),
),
),
],
),
leading: ad.logoUrl != null
? ClipRRect(
borderRadius: BorderRadius.circular(50.0),
child: Image.network(
ad.logoUrl.toString(),
errorBuilder: (context, error, stackTrace) {
ad.logoUrl = null;
return const SizedBox();
},
),
)
: null,
trailing: const Icon(FeatherIcons.externalLink),
),
);
}
}

View File

@@ -0,0 +1,22 @@
import 'package:refilc/models/ad.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'ad_tile.dart';
class AdViewable extends StatelessWidget {
const AdViewable(this.ad, {super.key});
final Ad ad;
@override
Widget build(BuildContext context) {
return AdTile(
ad,
onTap: () => launchUrl(
ad.launchUrl,
mode: LaunchMode.externalApplication,
),
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
class CardHandle extends StatelessWidget {
const CardHandle({super.key, this.child});
final Widget? child;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 42.0,
height: 4.0,
margin: const EdgeInsets.only(top: 12.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(45.0),
color: AppColors.of(context).text.withOpacity(0.10),
),
),
if (child != null) child!,
],
);
}
}

View File

@@ -0,0 +1,115 @@
import 'package:refilc/helpers/average_helper.dart';
import 'package:i18n_extension/i18n_widget.dart';
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:refilc_mobile_ui/common/widgets/cretification/certification_view.dart';
import 'package:refilc/ui/widgets/grade/grade_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'certification_card.i18n.dart';
class CertificationCard extends StatelessWidget {
const CertificationCard(this.grades,
{super.key, required this.gradeType, this.padding});
final List<Grade> grades;
final GradeType gradeType;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
String title = getGradeTypeTitle(gradeType);
double average = AverageHelper.averageEvals(grades, finalAvg: true);
String averageText = average.toStringAsFixed(1);
if (I18n.of(context).locale.languageCode != "en") {
averageText = averageText.replaceAll(".", ",");
}
Color color = gradeColor(context: context, value: average);
Color textColor;
if (color.computeLuminance() >= .5) {
textColor = Colors.black;
} else {
textColor = Colors.white;
}
return Padding(
padding:
padding ?? const EdgeInsets.symmetric(vertical: 2.0, horizontal: 6.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.0),
gradient: LinearGradient(
colors: [color, color.withOpacity(.75)],
),
),
child: Material(
type: MaterialType.transparency,
borderRadius: BorderRadius.circular(12.0),
child: ListTile(
contentPadding:
const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0)),
leading: Text(
averageText,
style: TextStyle(
color: textColor,
fontWeight: FontWeight.bold,
fontSize: 24.0,
),
),
title: Text.rich(
TextSpan(
text: title,
children: [
TextSpan(
text: "${grades.length}",
style: TextStyle(
color: textColor.withOpacity(.75),
fontWeight: FontWeight.w600,
fontSize: 16.0,
),
),
],
),
style: TextStyle(
color: textColor,
fontWeight: FontWeight.w700,
fontSize: 18.0,
),
),
trailing: Icon(FeatherIcons.arrowRight, color: textColor),
onTap: () => CertificationView.show(grades,
context: context, gradeType: gradeType),
),
),
),
);
}
}
String getGradeTypeTitle(GradeType gradeType) {
String title;
switch (gradeType) {
case GradeType.halfYear:
title = "mid".i18n;
break;
case GradeType.firstQ:
title = "1q".i18n;
break;
case GradeType.secondQ:
title = "2q".i18n;
break;
case GradeType.thirdQ:
title = "3q".i18n;
break;
case GradeType.fourthQ:
title = "4q".i18n;
break;
default:
title = "final".i18n;
}
return title;
}

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": {
"final": "Final grades",
"mid": "Midterm grades",
"1q": "1. Quarter grades",
"2q": "2. Quarter grades",
"3q": "3. Quarter grades",
"4q": "4. Quarter grades",
},
"hu_hu": {
"final": "Év végi jegyek",
"mid": "Félévi jegyek",
"1q": "1. Negyedéves jegyek",
"2q": "2. Negyedéves jegyek",
"3q": "3. Negyedéves jegyek",
"4q": "4. Negyedéves jegyek",
},
"de_de": {
"final": "Zeugnis Noten",
"mid": "Halbjährlich Noten",
"1q": "1. Quartal Noten",
"2q": "2. Quartal Noten",
"3q": "3. Quartal Noten",
"4q": "4. Quartal Noten",
}
};
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,108 @@
import 'package:refilc/helpers/subject.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:refilc/ui/widgets/grade/grade_tile.dart';
import 'package:refilc_mobile_ui/pages/grades/subject_grades_container.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:refilc/utils/format.dart';
import 'package:provider/provider.dart';
import 'certification_tile.i18n.dart';
class CertificationTile extends StatelessWidget {
const CertificationTile(this.grade, {super.key, this.onTap, this.padding});
final Function()? onTap;
final Grade grade;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
bool isSubjectView = SubjectGradesContainer.of(context) != null;
String certificationName;
SettingsProvider settingsProvider = Provider.of<SettingsProvider>(context);
switch (grade.type) {
case GradeType.endYear:
certificationName = "final".i18n;
break;
case GradeType.halfYear:
certificationName = "mid".i18n;
break;
case GradeType.firstQ:
certificationName = "1q".i18n;
break;
case GradeType.secondQ:
certificationName = "2q".i18n;
break;
case GradeType.thirdQ:
certificationName = "3q".i18n;
break;
case GradeType.fourthQ:
certificationName = "4q".i18n;
break;
case GradeType.levelExam:
certificationName = "equivalency".i18n;
break;
case GradeType.unknown:
default:
certificationName = "unknown".i18n;
}
return Material(
color: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.circular(8.0),
child: Padding(
padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0),
child: ListTile(
visualDensity: VisualDensity.compact,
contentPadding: isSubjectView
? const EdgeInsets.only(
left: 12.0, right: 12.0, top: 2.0, bottom: 8.0)
: const EdgeInsets.only(left: 8.0, right: 12.0),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
onTap: onTap,
leading: isSubjectView
? GradeValueWidget(
grade.value,
complemented: grade.description == 'Dicséret',
)
: Padding(
padding: const EdgeInsets.only(left: 2.0),
child: Icon(
SubjectIcon.resolveVariant(
subject: grade.subject, context: context),
size: 28.0,
color: AppColors.of(context).text.withOpacity(.75)),
),
minLeadingWidth: isSubjectView ? 32.0 : 42.0,
trailing: isSubjectView
? const Icon(FeatherIcons.award)
: GradeValueWidget(
grade.value,
complemented: grade.description == 'Dicséret',
),
title: Text(
isSubjectView
? certificationName
: grade.subject.renamedTo ?? grade.subject.name.capital(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 18.0,
fontStyle: grade.subject.isRenamed &&
settingsProvider.renamedSubjectsItalics
? FontStyle.italic
: null)),
subtitle: Text(grade.value.valueName,
style:
const TextStyle(fontWeight: FontWeight.w600, fontSize: 16.0)),
),
),
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"final": "Final",
"mid": "Mid year",
"1q": "1. Quarter",
"2q": "2. Quarter",
"3q": "3. Quarter",
"4q": "4. Quarter",
"equivalency": "Equivalency test",
"unknown": "Unknown",
"classavg": "Class Average",
},
"hu_hu": {
"final": "Év vége",
"mid": "Félév",
"1q": "1. Negyedév",
"2q": "2. Negyedév",
"3q": "3. Negyedév",
"4q": "4. Negyedév",
"equivalency": "Osztályozó",
"unknown": "Ismeretlen",
"classavg": "Osztályátlag",
},
"de_de": {
"final": "Zeugnis",
"mid": "Halbjährlich",
"1q": "1. Quartal",
"2q": "2. Quartal",
"3q": "3. Quartal",
"4q": "4. Quartal",
"equivalency": "Zulassungsprüfung",
"unknown": "Unbekannt",
"classavg": "Klassendurchschnitt",
}
};
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,43 @@
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:refilc_mobile_ui/common/panel/panel.dart';
import 'package:refilc_mobile_ui/common/widgets/cretification/certification_card.dart';
import 'package:refilc_mobile_ui/common/widgets/cretification/certification_tile.dart';
import 'package:refilc_mobile_ui/common/hero_scrollview.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
class CertificationView extends StatelessWidget {
const CertificationView(this.grades, {super.key, required this.gradeType});
final List<Grade> grades;
final GradeType gradeType;
static show(List<Grade> grades, {required BuildContext context, required GradeType gradeType}) =>
Navigator.of(context, rootNavigator: true).push(CupertinoPageRoute(builder: (context) => CertificationView(grades, gradeType: gradeType)));
@override
Widget build(BuildContext context) {
grades.sort((a, b) => a.subject.name.compareTo(b.subject.name));
List<Widget> tiles = grades.map((e) => CertificationTile(e)).toList();
return Scaffold(
body: HeroScrollView(
title: getGradeTypeTitle(gradeType),
icon: FeatherIcons.award,
iconSize: 50,
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0),
physics: const BouncingScrollPhysics(),
children: [
SafeArea(
child: Panel(
child: Column(
children: tiles,
),
),
)
],
)));
}
}

View File

@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
class CustomSwitch extends StatelessWidget {
final ValueChanged<bool> onChanged;
final bool value;
const CustomSwitch({
super.key,
required this.onChanged,
required this.value,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => onChanged(!value),
child: SizedBox(
height: 25,
width: 50,
child: Stack(
children: <Widget>[
AnimatedContainer(
height: 25,
width: 50,
curve: Curves.ease,
duration: const Duration(milliseconds: 400),
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(
Radius.circular(25.0),
),
color: value ? Theme.of(context).colorScheme.secondary : Theme.of(context).highlightColor,
),
),
AnimatedAlign(
curve: Curves.ease,
duration: const Duration(milliseconds: 400),
alignment: !value ? Alignment.centerLeft : Alignment.centerRight,
child: Container(
height: 20,
width: 20,
margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(0.1),
spreadRadius: 0.5,
blurRadius: 1,
)
],
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:refilc_kreta_api/models/event.dart';
import 'package:refilc/utils/format.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_image.dart';
import 'package:flutter/material.dart';
class EventTile extends StatelessWidget {
const EventTile(this.event, {super.key, this.onTap, this.padding});
final Event event;
final void Function()? onTap;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.circular(14.0),
child: Padding(
padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0),
child: Theme(
data: Theme.of(context).copyWith(
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
),
child: ListTile(
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.only(left: 8.0, right: 12.0),
onTap: onTap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14.0)),
leading: const ProfileImage(
name: "!",
radius: 19.2,
),
title: Text(
event.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
event.content.escapeHtml().replaceAll('\n', ' '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
minLeadingWidth: 0,
),
),
),
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:refilc_kreta_api/models/event.dart';
import 'package:refilc/utils/format.dart';
import 'package:refilc_mobile_ui/common/sliding_bottom_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_custom_tabs/flutter_custom_tabs.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
class EventView extends StatelessWidget {
const EventView(this.event, {super.key});
final Event event;
static void show(Event event, {required BuildContext context}) =>
showSlidingBottomSheet(context: context, child: EventView(event));
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Header
ListTile(
title: Text(
event.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
trailing: Text(
event.start.format(context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
// Details
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: SelectableLinkify(
text: event.content.escapeHtml(),
options: const LinkifyOptions(looseUrl: true, removeWww: true),
onOpen: (link) {
launch(link.url,
customTabsOption: CustomTabsOption(
toolbarColor: Theme.of(context).scaffoldBackgroundColor,
showPageTitle: true,
));
},
style: const TextStyle(fontWeight: FontWeight.w400),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,18 @@
import 'package:refilc_kreta_api/models/event.dart';
import 'package:refilc_mobile_ui/common/widgets/event/event_tile.dart';
import 'package:refilc_mobile_ui/common/widgets/event/event_view.dart';
import 'package:flutter/material.dart';
class EventViewable extends StatelessWidget {
const EventViewable(this.event, {super.key});
final Event event;
@override
Widget build(BuildContext context) {
return EventTile(
event,
onTap: () => EventView.show(event, context: context),
);
}
}

View File

@@ -0,0 +1,70 @@
import 'package:refilc/helpers/subject.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/exam.dart';
import 'package:refilc_mobile_ui/common/round_border_icon.dart';
import 'package:flutter/material.dart';
import 'package:refilc/utils/format.dart';
class ExamTile extends StatelessWidget {
const ExamTile(this.exam,
{super.key, this.onTap, this.padding, this.showSubject = true});
final Exam exam;
final void Function()? onTap;
final EdgeInsetsGeometry? padding;
final bool showSubject;
@override
Widget build(BuildContext context) {
return Material(
type: MaterialType.transparency,
child: Padding(
padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0),
child: ListTile(
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.only(left: 8.0, right: 10.0),
onTap: onTap,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14.0)),
leading: RoundBorderIcon(
icon: Icon(
Icons.edit_document,
size: showSubject ? 24.0 : 22.0,
weight: 2.5,
),
padding: showSubject ? 6.0 : 5.0,
width: 1.0,
),
title: Text(
showSubject
? exam.mode?.description ?? 'Számonkérés'
: (exam.description != ""
? exam.description
: (exam.mode?.description ?? "Számonkérés")),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
showSubject
? (exam.subject.isRenamed
? exam.subject.renamedTo!
: exam.subject.name.capital())
: exam.mode?.description ?? 'Számonkérés',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14.0),
),
trailing: showSubject
? Icon(
SubjectIcon.resolveVariant(
context: context, subject: exam.subject),
color: AppColors.of(context).text.withOpacity(.5),
)
: null,
minLeadingWidth: 0,
),
),
);
}
}

View File

@@ -0,0 +1,76 @@
import 'package:refilc/helpers/subject.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/exam.dart';
import 'package:refilc_mobile_ui/common/bottom_card.dart';
import 'package:refilc/utils/format.dart';
import 'package:refilc_mobile_ui/common/detail.dart';
import 'package:flutter/material.dart';
import 'exam_view.i18n.dart';
class ExamView extends StatelessWidget {
const ExamView(this.exam, {super.key});
final Exam exam;
static show(Exam exam, {required BuildContext context}) =>
showBottomCard(context: context, child: ExamView(exam));
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Header
ListTile(
leading: Padding(
padding: const EdgeInsets.only(left: 6.0),
child: Icon(
SubjectIcon.resolveVariant(
subject: exam.subject, context: context),
size: 36.0,
color: AppColors.of(context).text.withOpacity(.75),
),
),
title: Text(
exam.subject.isRenamed
? exam.subject.renamedTo!
: exam.subject.name.capital(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
(exam.teacher.isRenamed
? exam.teacher.renamedTo
: exam.teacher.name) ??
'',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
trailing: Text(
exam.date.format(context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
// Details
if (exam.writeDate.year != 0)
Detail(
title: "date".i18n,
description: exam.writeDate.format(context)),
if (exam.description != "")
Detail(
title: "description".i18n,
description: exam.description,
maxLines: 5),
if (exam.mode != null)
Detail(title: "mode".i18n, description: exam.mode!.description),
],
),
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"date": "Date",
"description": "Description",
"mode": "Type",
},
"hu_hu": {
"date": "Írás ideje",
"description": "Leírás",
"mode": "Típus",
},
"de_de": {
"date": "Prüfungszeit",
"description": "Bezeichnung",
"mode": "Typ",
}
};
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,27 @@
import 'package:refilc_kreta_api/models/exam.dart';
import 'package:refilc_mobile_ui/common/viewable.dart';
import 'package:refilc_mobile_ui/common/widgets/card_handle.dart';
import 'package:refilc_mobile_ui/common/widgets/exam/exam_tile.dart';
import 'package:refilc_mobile_ui/common/widgets/exam/exam_view.dart';
import 'package:flutter/material.dart';
class ExamViewable extends StatelessWidget {
const ExamViewable(this.exam,
{super.key, this.showSubject = true, this.tilePadding});
final Exam exam;
final bool showSubject;
final EdgeInsetsGeometry? tilePadding;
@override
Widget build(BuildContext context) {
return Viewable(
tile: ExamTile(
exam,
showSubject: showSubject,
padding: tilePadding,
),
view: CardHandle(child: ExamView(exam)),
);
}
}

View File

@@ -0,0 +1,99 @@
import 'package:refilc/helpers/subject.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc/utils/format.dart';
import 'package:refilc_kreta_api/models/subject.dart';
import 'package:refilc_mobile_ui/common/average_display.dart';
import 'package:refilc_mobile_ui/common/round_border_icon.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class GradeSubjectTile extends StatelessWidget {
const GradeSubjectTile(this.subject,
{super.key,
this.average = 0.0,
this.groupAverage = 0.0,
this.onTap,
this.averageBefore = 0.0});
final GradeSubject subject;
final void Function()? onTap;
final double average;
final double groupAverage;
final double averageBefore;
@override
Widget build(BuildContext context) {
Color textColor = AppColors.of(context).text;
SettingsProvider settingsProvider = Provider.of<SettingsProvider>(context);
// Failing indicator
if (average < 2.0 && average >= 1.0) {
textColor = AppColors.of(context).red;
}
final String changeIcon = average < averageBefore ? "" : "";
final Color changeColor = average < averageBefore
? Colors.redAccent
: Colors.lightGreenAccent.shade700;
return Material(
type: MaterialType.transparency,
child: ListTile(
minLeadingWidth: 32.0,
dense: true,
contentPadding: const EdgeInsets.only(left: 8.0, right: 6.0),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
visualDensity: VisualDensity.compact,
onTap: onTap,
leading: RoundBorderIcon(
icon: Icon(
SubjectIcon.resolveVariant(
context: context,
subject: subject,
),
size: 22.0,
weight: 2.5,
),
padding: 5.0,
width: 1.0,
),
title: Text(
subject.renamedTo ?? subject.name.capital(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16.0,
color: textColor,
fontStyle:
settingsProvider.renamedSubjectsItalics && subject.isRenamed
? FontStyle.italic
: null),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (groupAverage != 0 && averageBefore == 0.0)
AverageDisplay(average: groupAverage, border: true),
const SizedBox(width: 6.0),
if (averageBefore != 0.0 && averageBefore != average) ...[
AverageDisplay(average: averageBefore),
Padding(
padding:
const EdgeInsets.only(left: 6.0, right: 6.0, bottom: 3.5),
child: Text(
changeIcon,
style: TextStyle(
color: changeColor,
fontSize: 20.0,
),
),
)
],
AverageDisplay(average: average)
],
),
),
);
}
}

View File

@@ -0,0 +1,80 @@
import 'package:refilc/models/settings.dart';
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:refilc_mobile_ui/common/bottom_card.dart';
import 'package:refilc_mobile_ui/common/detail.dart';
import 'package:refilc/ui/widgets/grade/grade_tile.dart';
import 'package:refilc/utils/format.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'grade_view.i18n.dart';
class GradeView extends StatelessWidget {
const GradeView(this.grade, {super.key});
static show(Grade grade, {required BuildContext context}) =>
showBottomCard(context: context, child: GradeView(grade));
final Grade grade;
@override
Widget build(BuildContext context) {
SettingsProvider settingsProvider = Provider.of<SettingsProvider>(context);
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: GradeValueWidget(grade.value, fill: true),
title: Text(
grade.subject.renamedTo ?? grade.subject.name.capital(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontStyle: grade.subject.isRenamed &&
settingsProvider.renamedSubjectsItalics
? FontStyle.italic
: null),
),
subtitle: Text(
!Provider.of<SettingsProvider>(context, listen: false)
.presentationMode
? (grade.teacher.isRenamed
? grade.teacher.renamedTo
: grade.teacher.name) ??
''
: "Tanár",
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
trailing: Text(
grade.date.format(context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
// Grade Details
Detail(
title: "value".i18n,
description: "${grade.value.valueName} ${percentText()}",
),
if (grade.description != "")
Detail(title: "description".i18n, description: grade.description),
if (grade.mode.description != "")
Detail(title: "mode".i18n, description: grade.mode.description),
if (grade.writeDate.year != 0)
Detail(
title: "date".i18n,
description: grade.writeDate.format(context)),
],
),
);
}
String percentText() => grade.value.weight != 100 && grade.value.weight > 0
? "${grade.value.weight}%"
: "";
}

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": {
"value": "Value",
"date": "Date",
"description": "Description",
"mode": "Type",
},
"hu_hu": {
"value": "Érték",
"date": "Írás ideje",
"description": "Leírás",
"mode": "Típus",
},
"de_de": {
"value": "Notenwert",
"date": "Prüfungszeit",
"description": "Bezeichnung",
"mode": "Typ",
}
};
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 @@
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:refilc_mobile_ui/common/widgets/card_handle.dart';
import 'package:refilc/ui/widgets/grade/grade_tile.dart';
import 'package:refilc_mobile_ui/common/widgets/grade/grade_view.dart';
import 'package:refilc_mobile_ui/common/viewable.dart';
import 'package:refilc_mobile_ui/pages/grades/subject_grades_container.dart';
import 'package:flutter/material.dart';
class GradeViewable extends StatelessWidget {
const GradeViewable(this.grade, {super.key, this.padding});
final Grade grade;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
final subject = SubjectGradesContainer.of(context) != null;
final tile = GradeTile(grade, padding: subject ? EdgeInsets.zero : padding);
return Viewable(
tile: subject ? SubjectGradesContainer(child: tile) : tile,
view: CardHandle(child: GradeView(grade)),
);
}
}

View File

@@ -0,0 +1,164 @@
// ignore_for_file: use_build_context_synchronously
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:refilc_kreta_api/providers/grade_provider.dart';
import 'package:refilc_mobile_ui/common/widgets/grade/surprise_grade.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:rive/rive.dart';
import 'new_grades.i18n.dart';
class NewGradesSurprise extends StatelessWidget {
const NewGradesSurprise(this.grades, {super.key, this.censored = false});
final List<Grade> grades;
final bool censored;
@override
Widget build(BuildContext context) {
return Material(
type: MaterialType.transparency,
child: Padding(
padding: const EdgeInsets.only(left: 4.0, right: 4.0, top: 1.0),
child: ListTile(
shape: RoundedRectangleBorder(
side: BorderSide(
color: Theme.of(context).colorScheme.secondary,
width: 3.0,
),
borderRadius: BorderRadius.circular(14.0),
),
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.only(left: 8.0, right: 12.0),
onTap: () => openingFun(context),
minLeadingWidth: 54,
leading: SizedBox(
width: 44,
height: 44,
child: Center(
child: Container(
decoration: BoxDecoration(boxShadow: [
BoxShadow(
color:
Theme.of(context).colorScheme.secondary.withOpacity(.5),
blurRadius: 18.0,
)
]),
child: const RiveAnimation.asset(
"assets/animations/backpack-2.riv"),
),
),
),
title: censored
? Wrap(
children: [
Container(
width: 85,
height: 15,
decoration: BoxDecoration(
color: AppColors.of(context).text.withOpacity(.85),
borderRadius: BorderRadius.circular(8.0),
),
),
],
)
: Text(
grades.length == 1 ? "new_grade".i18n : "new_grades".i18n,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: censored
? Wrap(
children: [
Container(
width: 125,
height: 10,
decoration: BoxDecoration(
color: AppColors.of(context).text.withOpacity(.45),
borderRadius: BorderRadius.circular(8.0),
),
),
],
)
: Text(
"tap_to_open".i18n,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
trailing: censored
? Wrap(
children: [
Container(
width: 25,
height: 25,
decoration: BoxDecoration(
color: AppColors.of(context).text.withOpacity(.45),
borderRadius: BorderRadius.circular(25.0),
),
),
],
)
: Text.rich(
TextSpan(children: [
TextSpan(
text: "${grades.length}",
style: TextStyle(
shadows: [
Shadow(
color: AppColors.of(context).text.withOpacity(.2),
offset: const Offset(2, 2),
)
],
)),
TextSpan(
text: "x",
style: TextStyle(
fontSize: 20.0,
color: AppColors.of(context).text.withOpacity(.5),
fontWeight: FontWeight.w800,
),
)
]),
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 28.0,
color: AppColors.of(context).text.withOpacity(.75),
),
),
),
),
);
}
void openingFun(BuildContext context) {
final settings = Provider.of<SettingsProvider>(context, listen: false);
if (!settings.gradeOpeningFun) return;
final gradeProvider = Provider.of<GradeProvider>(context, listen: false);
final newGrades = gradeProvider.grades
.where((element) => element.date.isAfter(gradeProvider.lastSeenDate))
.toList();
newGrades.sort((a, b) => a.date.compareTo(b.date));
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(const Duration(milliseconds: 100));
for (final grade in newGrades) {
await showDialog(
context: context,
builder: (context) => SurpriseGrade(grade),
useRootNavigator: true,
barrierDismissible: false,
barrierColor: Colors.transparent,
useSafeArea: false,
);
await Future.delayed(const Duration(milliseconds: 300));
}
await gradeProvider.seenAll();
});
}
}

View File

@@ -0,0 +1,45 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"common": "Common",
"uncommon": "Uncommon",
"rare": "Rare",
"epic": "Epic",
"legendary": "Legendary",
"new_grade": "New grade",
"new_grades": "New grades",
"tap_to_open": "Tap to open now!",
"open_subtitle": "Tap to open...",
},
"hu_hu": {
"common": "Gyakori",
"uncommon": "Nem gyakori",
"rare": "Ritka",
"epic": "Epikus",
"legendary": "Legendás",
"new_grade": "Új jegy",
"new_grades": "Új jegyek",
"tap_to_open": "Nyisd ki őket!",
"open_subtitle": "Nyomd meg a kinyitáshoz...",
},
"de_de": {
"common": "Gemeinsam",
"uncommon": "Gelegentlich",
"rare": "Selten",
"epic": "Episch",
"legendary": "Legendär",
"new_grade": "Neue Note",
"new_grades": "Neue Noten",
"tap_to_open": "Tippen, um jetzt zu öffnen!",
"open_subtitle": "Antippen zum Öffnen...",
}
};
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,509 @@
// ignore_for_file: use_build_context_synchronously
import 'dart:math';
import 'dart:ui';
import 'package:animated_background/animated_background.dart' as bg;
import 'package:refilc/api/providers/database_provider.dart';
import 'package:refilc/api/providers/user_provider.dart';
import 'package:refilc/helpers/subject.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/ui/widgets/grade/grade_tile.dart';
import 'package:refilc_kreta_api/models/grade.dart';
import 'package:refilc_mobile_ui/pages/home/particle.dart';
import 'package:flutter/material.dart';
import 'package:refilc/utils/format.dart';
import 'package:provider/provider.dart';
import 'package:rive/rive.dart' as rive;
import 'new_grades.i18n.dart';
class SurpriseGrade extends StatefulWidget {
const SurpriseGrade(this.grade, {super.key});
final Grade grade;
@override
State<SurpriseGrade> createState() => _SurpriseGradeState();
}
class _SurpriseGradeState extends State<SurpriseGrade>
with TickerProviderStateMixin {
late AnimationController _revealAnimFade;
late AnimationController _revealAnimScale;
late AnimationController _revealAnimGrade;
late AnimationController _revealAnimParticle;
late rive.RiveAnimationController _controller;
late SettingsProvider settingsProvider;
List<String> defaultRarities = [
"common",
"uncommon",
"rare",
"epic",
"legendary",
];
Map<String, String> rarities = {};
@override
void initState() {
super.initState();
_revealAnimFade = AnimationController(
vsync: this, duration: const Duration(milliseconds: 500));
_revealAnimScale = AnimationController(
vsync: this, duration: const Duration(milliseconds: 300));
_revealAnimGrade =
AnimationController(vsync: this, duration: const Duration(seconds: 1));
_revealAnimParticle =
AnimationController(vsync: this, duration: const Duration(seconds: 2));
_revealAnimScale.animateTo(0.7, duration: Duration.zero);
_controller = rive.SimpleAnimation('Timeline 1', autoplay: false);
WidgetsBinding.instance.addPostFrameCallback((_) {
_revealAnimFade.animateTo(1.0, curve: Curves.easeInOut);
Future.delayed(const Duration(milliseconds: 200), () {
_revealAnimScale.animateTo(1.0, curve: Curves.easeInOut).then((_) {
setState(() => subtitle = true);
});
});
_fetchRarities();
});
seed = Random().nextInt(100000000);
}
@override
void dispose() {
_revealAnimFade.dispose();
_revealAnimScale.dispose();
_revealAnimGrade.dispose();
_revealAnimParticle.dispose();
_controller.dispose();
super.dispose();
}
_fetchRarities() async {
rarities = await Provider.of<DatabaseProvider>(context, listen: false)
.userQuery
.getGradeRarities(
userId: Provider.of<UserProvider>(context, listen: false).id!);
setState(() {});
}
bool hold = false;
bool subtitle = false;
late int seed;
void reveal() async {
if (!subtitle) {
_revealAnimParticle.animateBack(0.0,
curve: Curves.fastLinearToSlowEaseIn,
duration: const Duration(milliseconds: 300));
await Future.delayed(const Duration(milliseconds: 50));
_revealAnimGrade.animateBack(0.0, curve: Curves.fastLinearToSlowEaseIn);
await Future.delayed(const Duration(milliseconds: 50));
_revealAnimFade.animateBack(0.0, curve: Curves.easeInOut);
_revealAnimScale.animateBack(0.0, curve: Curves.easeInOut);
if (mounted) Navigator.of(context).pop();
return;
}
subtitle = false;
setState(() => hold = false);
_controller.isActive = true;
await Future.delayed(const Duration(seconds: 2));
if (mounted) {
_revealAnimGrade.animateTo(1.0, curve: Curves.fastLinearToSlowEaseIn);
}
await Future.delayed(const Duration(milliseconds: 700));
if (mounted) {
await _revealAnimParticle.animateTo(1.0,
curve: Curves.fastLinearToSlowEaseIn);
}
}
@override
Widget build(BuildContext context) {
settingsProvider = Provider.of<SettingsProvider>(context);
return AnimatedBuilder(
animation: _revealAnimFade,
builder: (context, child) {
return FadeTransition(
opacity: _revealAnimFade,
child: Material(
color: Colors.black.withOpacity(.75),
child: Container(
color: Theme.of(context).colorScheme.secondary.withOpacity(.05),
child: Container(
decoration: const BoxDecoration(
gradient: RadialGradient(
colors: [Colors.transparent, Colors.black],
radius: 1.5,
stops: [0.2, 1.0],
),
),
child: bg.AnimatedBackground(
vsync: this,
behaviour: bg.RandomParticleBehaviour(
options: bg.ParticleOptions(
baseColor: Theme.of(context).colorScheme.secondary,
spawnMinSpeed: 5.0,
spawnMaxSpeed: 10.0,
minOpacity: .05,
maxOpacity: .08,
spawnMinRadius: 30.0,
spawnMaxRadius: 50.0,
particleCount: 20,
),
),
child: ScaleTransition(
scale: _revealAnimScale,
child: child,
),
),
),
),
),
);
},
child: AnimatedBuilder(
animation: _revealAnimGrade,
builder: (context, child) {
return Stack(
alignment: Alignment.center,
children: [
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SlideTransition(
position: _revealAnimGrade.drive(
Tween(begin: Offset.zero, end: const Offset(0, 0.7))),
child: AnimatedScale(
scale: hold ? 1.1 : 1.0,
curve: Curves.easeOutBack,
duration: const Duration(milliseconds: 200),
child: GestureDetector(
onLongPressDown: (_) => setState(() => hold = true),
onLongPressEnd: (_) => reveal(),
onLongPressCancel: reveal,
child: ScaleTransition(
scale: CurvedAnimation(
curve: Curves.easeInOut,
parent: _revealAnimGrade
.drive(Tween(begin: 1.0, end: 0.8))),
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: 300,
height: 300,
child: rive.RiveAnimation.asset(
"assets/animations/backpack-2.riv",
fit: BoxFit.contain,
controllers: [_controller],
antialiasing: false,
),
),
SlideTransition(
position: _revealAnimParticle.drive(Tween(
begin: const Offset(0, 0.3),
end: const Offset(0, 0.8))),
child: FadeTransition(
opacity: _revealAnimParticle,
child: ClipRRect(
borderRadius: BorderRadius.circular(24.0),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 32.0, sigmaY: 32.0),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 32.0, vertical: 20.0),
decoration: BoxDecoration(
color: Colors.white.withOpacity(.3),
borderRadius:
BorderRadius.circular(24.0),
border: Border.all(
color: Colors.black
.withOpacity(.3),
width: 1.0),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisSize:
MainAxisSize.min,
children: [
if (widget.grade
.description !=
"")
Text(
widget
.grade.description,
style: const TextStyle(
color: Colors.white,
fontWeight:
FontWeight.bold,
fontSize: 26.0,
),
maxLines: 2,
overflow: TextOverflow
.ellipsis,
),
Text(
widget.grade.subject
.renamedTo ??
widget.grade.subject
.name
.capital(),
style: TextStyle(
color: Colors.white
.withOpacity(.8),
fontWeight:
FontWeight.bold,
fontSize: 24.0,
fontStyle: widget
.grade
.subject
.isRenamed &&
settingsProvider
.renamedSubjectsItalics
? FontStyle.italic
: null),
maxLines: 2,
overflow:
TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
"${widget.grade.value.weight}%",
style: TextStyle(
color: Colors.white
.withOpacity(.7),
fontWeight:
FontWeight.w600,
fontSize: 20.0,
),
maxLines: 2,
overflow:
TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 20.0),
Icon(
SubjectIcon.resolveVariant(
subject:
widget.grade.subject,
context: context),
color: Colors.white,
size: 82.0,
),
],
),
),
),
),
),
),
],
),
),
),
),
),
const SizedBox(height: 42.0),
AnimatedOpacity(
opacity: subtitle ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: Text(
"open_subtitle".i18n,
style: TextStyle(
color: Colors.white.withOpacity(.8),
fontWeight: FontWeight.w600,
fontSize: 24.0,
),
),
),
],
),
if (_revealAnimGrade.value > 0)
AnimatedBuilder(
animation: _revealAnimParticle,
builder: (context, child) {
bool shouldPaint = false;
if (_revealAnimParticle.status ==
AnimationStatus.forward ||
_revealAnimParticle.status ==
AnimationStatus.reverse) {
shouldPaint = true;
}
String? rr =
rarities[widget.grade.value.value.toString()];
rr ??= '';
if (rr.replaceAll(' ', '') == '') {
rr = defaultRarities[widget.grade.value.value - 1].i18n;
}
return ScaleTransition(
scale: _revealAnimGrade,
child: FadeTransition(
opacity: _revealAnimGrade,
child: SlideTransition(
position: _revealAnimGrade.drive(Tween(
begin: Offset.zero,
end: const Offset(0, -0.6))),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SlideTransition(
position: _revealAnimGrade.drive(Tween(
begin: Offset.zero,
end: const Offset(0, -0.9))),
child: Text(
rr,
style: TextStyle(
fontSize: 46.0,
fontWeight: FontWeight.bold,
color: gradeColor(
context: context,
value: widget.grade.value.value),
shadows: [
Shadow(
color: gradeColor(
context: context,
value:
widget.grade.value.value)
.withOpacity(.5),
blurRadius: 24.0,
),
Shadow(
color: gradeColor(
context: context,
value:
widget.grade.value.value)
.withOpacity(.3),
offset: const Offset(-3, -3),
),
],
),
),
),
const SizedBox(height: 32.0),
ScaleTransition(
scale: CurvedAnimation(
curve: Curves.easeInOutBack,
parent: _revealAnimParticle
.drive(Tween(begin: 0.6, end: 1.0))),
child: CustomPaint(
painter: PimpPainter(
particle: Sprinkles(),
controller: _revealAnimParticle,
seed: seed + 1,
shouldPaint: shouldPaint,
),
child: CustomPaint(
painter: PimpPainter(
particle: Sprinkles(),
controller: _revealAnimParticle,
seed: seed,
shouldPaint: shouldPaint,
),
child: RotationTransition(
turns: CurvedAnimation(
curve: Curves.easeInBack,
parent: _revealAnimGrade)
.drive(
Tween(begin: 0.95, end: 1.0)),
child: GradeValueWidget(
widget.grade.value,
fill: true,
contrast: true,
shadow: true,
outline: true,
size: 100.0,
),
),
),
),
),
],
),
),
),
);
},
),
],
);
}),
);
}
}
class PimpPainter extends CustomPainter {
PimpPainter(
{required this.particle,
required this.seed,
required this.controller,
required this.shouldPaint})
: super(repaint: controller);
final Particle particle;
final int seed;
final AnimationController controller;
final bool shouldPaint;
@override
void paint(Canvas canvas, Size size) {
if (shouldPaint) {
canvas.translate(size.width / 2, size.height / 2);
particle.paint(canvas, size, controller.value, seed);
}
}
@override
bool shouldRepaint(PimpPainter oldDelegate) => shouldPaint;
}
Color randomColor(int c) {
c = c % 5;
if (c == 0) return Colors.red.shade300;
if (c == 1) return Colors.green.shade300;
if (c == 2) return Colors.orange.shade300;
if (c == 3) return Colors.blue.shade300;
if (c == 4) return Colors.pink.shade300;
if (c == 5) return Colors.brown.shade300;
return Colors.black;
}
class Sprinkles extends Particle {
@override
void paint(Canvas canvas, Size size, progress, seed) {
Random random = Random(seed);
int randomMirrorOffset = random.nextInt(8) + 1;
CompositeParticle(children: [
Firework(),
RectangleMirror.builder(
numberOfParticles: 6,
particleBuilder: (n) {
return AnimatedPositionedParticle(
begin: const Offset(0.0, -10.0),
end: const Offset(0.0, -60.0),
child:
FadingRect(width: 5.0, height: 15.0, color: randomColor(n)),
);
},
initialDistance: -pi / randomMirrorOffset),
]).paint(canvas, size, progress, seed);
}
}

View File

@@ -0,0 +1,92 @@
import 'dart:io';
import 'package:refilc/helpers/attachment_helper.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_mobile_ui/common/custom_snack_bar.dart';
import 'package:refilc_mobile_ui/common/widgets/message/image_view.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:refilc_kreta_api/models/homework.dart';
import 'package:flutter/material.dart';
import 'homework_attachment_tile.i18n.dart';
class HomeworkAttachmentTile extends StatelessWidget {
const HomeworkAttachmentTile(this.attachment, {super.key});
final HomeworkAttachment attachment;
Widget buildImage(BuildContext context) {
return FutureBuilder<String>(
future: attachment.download(context),
builder: (context, snapshot) {
return snapshot.hasData
? Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Material(
child: InkWell(
onTap: () {
Navigator.of(context, rootNavigator: true)
.push(MaterialPageRoute(
builder: (context) => ImageView(snapshot.data!),
));
},
borderRadius: BorderRadius.circular(12.0),
child: Ink.image(
image: FileImage(File(snapshot.data ?? "")),
height: 200.0,
width: double.infinity,
fit: BoxFit.cover,
),
),
),
),
)
: Center(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.secondary),
));
},
);
}
@override
Widget build(BuildContext context) {
if (attachment.isImage) return buildImage(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: InkWell(
borderRadius: BorderRadius.circular(12.0),
onTap: () {
attachment.open(context).then((value) {
if (!value) {
ScaffoldMessenger.of(context).showSnackBar(CustomSnackBar(
context: context,
content: Text("Failed to open attachment".i18n),
backgroundColor: AppColors.of(context).red,
duration: const Duration(seconds: 1),
));
}
});
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(children: [
const Icon(FeatherIcons.paperclip),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Text(attachment.name,
maxLines: 2, overflow: TextOverflow.ellipsis),
),
),
]),
),
),
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"Failed to open attachment": "Failed to open attachment",
},
"hu_hu": {
"Failed to open attachment": "Nem sikerült megnyitni a mellékletet",
},
"de_de": {
"Failed to open attachment": "Anhang konnte nicht geöffnet 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,115 @@
import 'package:refilc/helpers/subject.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/homework.dart';
import 'package:refilc/utils/format.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:provider/provider.dart';
class HomeworkTile extends StatelessWidget {
const HomeworkTile(
this.homework, {
super.key,
this.onTap,
this.padding,
this.censored = false,
});
final Homework homework;
final void Function()? onTap;
final EdgeInsetsGeometry? padding;
final bool censored;
@override
Widget build(BuildContext context) {
SettingsProvider settingsProvider = Provider.of<SettingsProvider>(context);
return Padding(
padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0),
child: ListTile(
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.only(left: 8.0, right: 12.0),
onTap: onTap,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
leading: SizedBox(
width: 44,
height: 44,
child: censored
? Container(
decoration: BoxDecoration(
color: AppColors.of(context).text.withOpacity(.55),
borderRadius: BorderRadius.circular(60.0),
),
)
: Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Icon(
SubjectIcon.resolveVariant(
subject: homework.subject, context: context),
size: 28.0,
color: AppColors.of(context).text.withOpacity(.75),
),
),
),
title: censored
? Wrap(
children: [
Container(
width: 160,
height: 15,
decoration: BoxDecoration(
color: AppColors.of(context).text.withOpacity(.85),
borderRadius: BorderRadius.circular(8.0),
),
),
],
)
: Text(
homework.subject.renamedTo ?? homework.subject.name.capital(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontStyle: homework.subject.isRenamed &&
settingsProvider.renamedSubjectsItalics
? FontStyle.italic
: null),
),
subtitle: censored
? Wrap(
children: [
Container(
width: 100,
height: 10,
decoration: BoxDecoration(
color: AppColors.of(context).text.withOpacity(.45),
borderRadius: BorderRadius.circular(8.0),
),
),
],
)
: Text(
homework.content.escapeHtml().replaceAll('\n', ' '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
trailing: censored
? Container(
width: 15,
height: 15,
decoration: BoxDecoration(
color: AppColors.of(context).text.withOpacity(.45),
borderRadius: BorderRadius.circular(8.0),
),
)
: Icon(
FeatherIcons.home,
color: AppColors.of(context).text.withOpacity(.75),
),
minLeadingWidth: 0,
),
);
}
}

View File

@@ -0,0 +1,104 @@
import 'package:refilc/helpers/subject.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc_kreta_api/models/homework.dart';
import 'package:refilc/utils/format.dart';
import 'package:refilc_mobile_ui/common/detail.dart';
import 'package:refilc_mobile_ui/common/sliding_bottom_sheet.dart';
import 'package:refilc_mobile_ui/common/widgets/homework/homework_attachment_tile.dart';
import 'package:flutter/material.dart';
import 'package:flutter_custom_tabs/flutter_custom_tabs.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:provider/provider.dart';
import 'homework_view.i18n.dart';
class HomeworkView extends StatelessWidget {
const HomeworkView(this.homework, {super.key});
final Homework homework;
static show(Homework homework, {required BuildContext context}) {
showSlidingBottomSheet(context: context, child: HomeworkView(homework));
}
@override
Widget build(BuildContext context) {
List<Widget> attachmentTiles = [];
SettingsProvider settingsProvider = Provider.of<SettingsProvider>(context);
for (var attachment in homework.attachments) {
attachmentTiles.add(Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: HomeworkAttachmentTile(
attachment,
),
));
}
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Header
ListTile(
leading: Icon(
SubjectIcon.resolveVariant(
subject: homework.subject, context: context),
size: 36.0,
),
title: Text(
homework.subject.renamedTo ?? homework.subject.name.capital(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontStyle: homework.subject.isRenamed &&
settingsProvider.renamedSubjectsItalics
? FontStyle.italic
: null),
),
subtitle: Text(
(homework.teacher.isRenamed
? homework.teacher.renamedTo
: homework.teacher.name) ??
'',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
trailing: Text(
homework.date.format(context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
// Details
if (homework.deadline.year != 0)
Detail(
title: "deadline".i18n,
description: homework.deadline.format(context)),
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 18.0, vertical: 6.0),
child: SelectableLinkify(
text: homework.content.escapeHtml(),
options: const LinkifyOptions(looseUrl: true, removeWww: true),
onOpen: (link) {
launch(link.url,
customTabsOption: CustomTabsOption(
toolbarColor: Theme.of(context).scaffoldBackgroundColor,
showPageTitle: true,
));
},
style: const TextStyle(fontWeight: FontWeight.w400),
),
),
// Attachments
...attachmentTiles,
],
),
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"deadline": "Deadline",
},
"hu_hu": {
"deadline": "Határidő",
},
"de_de": {
"deadline": "Termin",
}
};
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,18 @@
import 'package:refilc_kreta_api/models/homework.dart';
import 'package:refilc_mobile_ui/common/widgets/homework/homework_tile.dart';
import 'package:refilc_mobile_ui/common/widgets/homework/homework_view.dart';
import 'package:flutter/material.dart';
class HomeworkViewable extends StatelessWidget {
const HomeworkViewable(this.homework, {super.key});
final Homework homework;
@override
Widget build(BuildContext context) {
return HomeworkTile(
homework,
onTap: () => HomeworkView.show(homework, context: context),
);
}
}

View File

@@ -0,0 +1,88 @@
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/lesson.dart';
import 'package:flutter/material.dart';
import 'package:refilc/utils/format.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:provider/provider.dart';
import 'changed_lesson_tile.i18n.dart';
class ChangedLessonTile extends StatelessWidget {
const ChangedLessonTile(this.lesson, {super.key, this.onTap, this.padding});
final Lesson lesson;
final void Function()? onTap;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
SettingsProvider settingsProvider = Provider.of<SettingsProvider>(context);
String lessonIndexTrailing = "";
// Only put a trailing . if its a digit
if (RegExp(r'\d').hasMatch(lesson.lessonIndex)) lessonIndexTrailing = ".";
Color accent = Theme.of(context).colorScheme.secondary;
if (lesson.substituteTeacher?.name != '') {
accent = AppColors.of(context).yellow;
}
if (lesson.status?.name == "Elmaradt") {
accent = AppColors.of(context).red;
}
return Material(
color: Theme.of(context).colorScheme.background,
borderRadius: BorderRadius.circular(14.0),
child: Padding(
padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0),
child: ListTile(
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.only(left: 8.0, right: 12.0),
onTap: onTap,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(14.0)),
leading: SizedBox(
width: 44.0,
height: 44.0,
child: Center(
child: Text(
lesson.lessonIndex + lessonIndexTrailing,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 30.0,
fontWeight: FontWeight.w600,
color: accent,
),
),
),
),
title: Text(
lesson.status?.name == "Elmaradt" &&
lesson.substituteTeacher?.name != ""
? "cancelled".i18n
: "substituted".i18n,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
lesson.subject.renamedTo ?? lesson.subject.name.capital(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w500,
fontStyle: lesson.subject.isRenamed &&
settingsProvider.renamedSubjectsItalics
? FontStyle.italic
: null),
),
trailing: const Icon(FeatherIcons.arrowRight),
minLeadingWidth: 0,
),
),
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"cancelled": "Cancelled lesson",
"substituted": "Substituted lesson",
},
"hu_hu": {
"cancelled": "Elmaradó óra",
"substituted": "Helyettesített óra",
},
"de_de": {
"cancelled": "Abgesagte Stunde",
"substituted": "Vertretene Stunden",
}
};
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,18 @@
import 'package:refilc_kreta_api/models/lesson.dart';
import 'package:refilc_mobile_ui/common/widgets/lesson/changed_lesson_tile.dart';
import 'package:refilc_mobile_ui/pages/timetable/timetable_page.dart';
import 'package:flutter/material.dart';
class ChangedLessonViewable extends StatelessWidget {
const ChangedLessonViewable(this.lesson, {super.key});
final Lesson lesson;
@override
Widget build(BuildContext context) {
return ChangedLessonTile(
lesson,
onTap: () => TimetablePage.jump(context, lesson: lesson),
);
}
}

View File

@@ -0,0 +1,120 @@
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc/utils/format.dart';
import 'package:refilc_kreta_api/models/lesson.dart';
import 'package:refilc_mobile_ui/common/bottom_card.dart';
import 'package:refilc_mobile_ui/common/detail.dart';
import 'package:refilc_mobile_ui/common/round_border_icon.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'lesson_view.i18n.dart';
class LessonView extends StatelessWidget {
const LessonView(this.lesson, {super.key});
final Lesson lesson;
@override
Widget build(BuildContext context) {
Color accent = AppColors.of(context).text;
String lessonIndexTrailing = "";
SettingsProvider settingsProvider = Provider.of<SettingsProvider>(context);
if (RegExp(r'\d').hasMatch(lesson.lessonIndex)) lessonIndexTrailing = ".";
if (lesson.substituteTeacher != null &&
lesson.substituteTeacher?.name != "") {
accent = AppColors.of(context).yellow;
}
if (lesson.status?.name == "Elmaradt") {
accent = AppColors.of(context).red;
}
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Header
ListTile(
leading: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: RoundBorderIcon(
color: accent,
width: 1.0,
icon: SizedBox(
width: 25,
height: 25,
child: Center(
child: Padding(
padding: const EdgeInsets.only(left: 3.0),
child: Text(
lesson.lessonIndex + lessonIndexTrailing,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 17.5,
fontWeight: FontWeight.w700,
color: accent,
),
),
),
),
),
),
),
title: Text(
lesson.subject.renamedTo ?? lesson.subject.name.capital(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w600,
fontStyle: lesson.subject.isRenamed &&
settingsProvider.renamedSubjectsItalics
? FontStyle.italic
: null),
),
subtitle: Text(
((lesson.substituteTeacher == null ||
lesson.substituteTeacher!.name == "")
? (lesson.teacher.isRenamed
? lesson.teacher.renamedTo
: lesson.teacher.name)
: (lesson.substituteTeacher!.isRenamed
? lesson.substituteTeacher!.renamedTo
: lesson.substituteTeacher!.name)) ??
'',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
trailing: Text(
lesson.date.format(context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
// Details
if (lesson.room != "")
Detail(
title: "Room".i18n,
description: lesson.room.replaceAll("_", " ")),
if (lesson.description != "")
Detail(title: "Description".i18n, description: lesson.description),
if (lesson.lessonYearIndex != null)
Detail(
title: "Lesson Number".i18n,
description: "${lesson.lessonYearIndex}."),
if (lesson.groupName != "")
Detail(title: "Group".i18n, description: lesson.groupName),
],
),
);
}
static show(Lesson lesson, {required BuildContext context}) {
showBottomCard(context: context, child: LessonView(lesson));
}
}

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": {
"Room": "Room",
"Description": "Description",
"Lesson Number": "Lesson Number",
"Group": "Group",
},
"hu_hu": {
"Room": "Terem",
"Description": "Leírás",
"Lesson Number": "Éves óraszám",
"Group": "Csoport",
},
"de_de": {
"Room": "Raum",
"Description": "Bezeichnung",
"Lesson Number": "Ordinalzahl",
"Group": "Gruppe",
}
};
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 @@
import 'package:refilc_kreta_api/models/lesson.dart';
import 'package:refilc_mobile_ui/common/viewable.dart';
import 'package:refilc_mobile_ui/common/widgets/card_handle.dart';
import 'package:refilc/ui/widgets/lesson/lesson_tile.dart';
import 'package:refilc_mobile_ui/common/widgets/lesson/lesson_view.dart';
import 'package:flutter/material.dart';
class LessonViewable extends StatelessWidget {
const LessonViewable(this.lesson, {super.key, this.swapDesc = false});
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 Viewable(
tile: tile,
view: CardHandle(child: LessonView(lesson)),
);
}
}

View File

@@ -0,0 +1,85 @@
import 'dart:io';
import 'package:refilc_kreta_api/models/attachment.dart';
import 'package:refilc/helpers/attachment_helper.dart';
import 'package:refilc_mobile_ui/common/widgets/message/image_view.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
class AttachmentTile extends StatelessWidget {
const AttachmentTile(this.attachment, {super.key});
final Attachment attachment;
Widget buildImage(BuildContext context) {
return FutureBuilder<String>(
future: attachment.download(context),
builder: (context, snapshot) {
if (snapshot.hasData) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(12.0),
child: Material(
child: InkWell(
onTap: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) {
return ImageView(snapshot.data!);
},
);
},
borderRadius: BorderRadius.circular(12.0),
child: Ink.image(
image: FileImage(File(snapshot.data ?? "")),
height: 200.0,
width: double.infinity,
fit: BoxFit.cover,
),
),
),
),
);
} else {
return Center(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.secondary),
));
}
},
);
}
@override
Widget build(BuildContext context) {
if (attachment.isImage) return buildImage(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: InkWell(
borderRadius: BorderRadius.circular(12.0),
onTap: () {
attachment.open(context);
},
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Row(children: [
const Icon(FeatherIcons.paperclip),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: Text(attachment.name,
maxLines: 2, overflow: TextOverflow.ellipsis),
),
),
]),
),
),
);
}
}

View File

@@ -0,0 +1,47 @@
import 'dart:io';
import 'package:refilc/helpers/share_helper.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:photo_view/photo_view.dart';
class ImageView extends StatelessWidget {
const ImageView(this.path, {super.key});
final String path;
@override
Widget build(BuildContext context) {
return Material(
color: Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(
minimum: const EdgeInsets.only(top: 24.0),
child: Scaffold(
appBar: AppBar(
leading: BackButton(color: AppColors.of(context).text),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
onPressed: () => ShareHelper.shareFile(path),
icon: Icon(FeatherIcons.share2,
color: AppColors.of(context).text),
splashRadius: 24.0,
),
),
],
),
body: PhotoView(
imageProvider: FileImage(File(path)),
maxScale: 4.0,
minScale: PhotoViewComputedScale.contained,
backgroundDecoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
),
),
),
),
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/message.dart';
import 'package:refilc_mobile_ui/common/widgets/message/message_view_tile.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
class MessageView extends StatefulWidget {
const MessageView(this.messages, {super.key});
final List<Message> messages;
static show(List<Message> messages, {required BuildContext context}) =>
Navigator.of(context, rootNavigator: true).push(
CupertinoPageRoute(builder: (context) => MessageView(messages)));
@override
MessageViewState createState() => MessageViewState();
}
class MessageViewState extends State<MessageView> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leadingWidth: 64.0,
leading: BackButton(color: AppColors.of(context).text),
elevation: 0,
actions: const [
// Padding(
// padding: EdgeInsets.only(right: 8.0),
// child: IconButton(
// onPressed: () {},
// icon: Icon(FeatherIcons.archive, color: AppColors.of(context).text),
// splashRadius: 32.0,
// ),
// ),
],
),
body: SafeArea(
child: ListView.builder(
padding: EdgeInsets.zero,
physics: const BouncingScrollPhysics(),
itemCount: widget.messages.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24.0),
child: MessageViewTile(widget.messages[index]),
);
},
),
),
);
}
}

View File

@@ -0,0 +1,188 @@
import 'package:refilc/api/providers/user_provider.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/message.dart';
import 'package:refilc_mobile_ui/common/panel/panel.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_image.dart';
import 'package:refilc_mobile_ui/common/widgets/message/attachment_tile.dart';
import 'package:flutter/material.dart';
import 'package:refilc/utils/format.dart';
import 'package:flutter_custom_tabs/flutter_custom_tabs.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
// import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:i18n_extension/i18n_widget.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:share_plus/share_plus.dart';
import 'message_view_tile.i18n.dart';
class MessageViewTile extends StatelessWidget {
const MessageViewTile(this.message, {super.key});
final Message message;
@override
Widget build(BuildContext context) {
UserProvider user = Provider.of<UserProvider>(context, listen: false);
String recipientLabel = "";
if (message.recipients.any((r) => r.name == user.student?.name)) {
recipientLabel = "me".i18n;
}
if (recipientLabel != "" && message.recipients.length > 1) {
recipientLabel += " +";
recipientLabel += message.recipients
.where((r) => r.name != user.student?.name)
.length
.toString();
}
if (recipientLabel == "") {
// note: convertint to set to remove duplicates
recipientLabel +=
message.recipients.map((r) => r.name).toSet().join(", ");
}
List<Widget> attachments = [];
for (var a in message.attachments) {
attachments.add(AttachmentTile(a));
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// subject
Center(
child: Text(
message.subject,
softWrap: true,
maxLines: 10,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 24.0,
height: 1.2,
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
),
const SizedBox(height: 8.0),
// date
Center(
child: Text(
DateFormat("yyyy. MM. dd.", I18n.locale.languageCode)
.format(message.date),
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
),
const SizedBox(height: 28.0),
// author
Container(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.0),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withOpacity(.25),
width: 1.0,
),
),
child: ListTile(
visualDensity: VisualDensity.compact,
contentPadding: EdgeInsets.zero,
leading: ProfileImage(
isNotePfp: true,
name: message.author,
backgroundColor: Theme.of(context).colorScheme.secondary,
radius: 19.0,
),
title: Text(
message.author,
style: TextStyle(
fontWeight: FontWeight.w500,
color: Theme.of(context).textTheme.bodySmall?.color,
),
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
subtitle: Text(
"${"to".i18n} $recipientLabel",
style: TextStyle(
fontWeight: FontWeight.w500,
height: 1.2,
color: Theme.of(context)
.textTheme
.bodySmall
?.color
?.withOpacity(0.6),
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// IconButton(
// onPressed: () {},
// icon: Icon(FeatherIcons.cornerUpLeft,
// color: AppColors.of(context).text),
// splashRadius: 24.0,
// highlightColor: Colors.transparent,
// padding: EdgeInsets.zero,
// visualDensity: VisualDensity.compact,
// ),
IconButton(
onPressed: () {
Share.share(
message.content.escapeHtml(),
subject: 'reFilc',
);
},
icon: Icon(
FeatherIcons.share2,
color: AppColors.of(context).text,
size: 20,
),
splashRadius: 20.0,
highlightColor: Colors.transparent,
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
],
),
),
),
const SizedBox(height: 10.0),
// content
Panel(
padding: const EdgeInsets.all(12.0),
child: SelectableLinkify(
text: message.content.escapeHtml(),
options: const LinkifyOptions(looseUrl: true, removeWww: true),
onOpen: (link) {
launch(link.url,
customTabsOption: CustomTabsOption(
toolbarColor: Theme.of(context).scaffoldBackgroundColor,
showPageTitle: true,
));
},
style: const TextStyle(fontWeight: FontWeight.w400),
),
),
// Attachments
...attachments,
],
),
);
}
}

View File

@@ -0,0 +1,24 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"me": "me",
"to": "to",
},
"hu_hu": {
"me": "én",
"to": "Címzett:",
},
"de_de": {
"me": "mich",
"to": "zu",
}
};
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,34 @@
import 'package:animations/animations.dart';
import 'package:refilc_kreta_api/models/message.dart';
import 'package:refilc/ui/widgets/message/message_tile.dart';
import 'package:refilc_mobile_ui/common/widgets/message/message_view.dart';
import 'package:flutter/material.dart';
class MessageViewable extends StatelessWidget {
const MessageViewable(this.message, {super.key});
final Message message;
@override
Widget build(BuildContext context) {
return OpenContainer(
openBuilder: (context, _) {
return MessageView([message]);
},
closedBuilder: (context, VoidCallback openContainer) {
return MessageTile(message);
},
closedElevation: 0,
openShape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
closedShape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
middleColor: Theme.of(context).colorScheme.background,
openColor: Theme.of(context).scaffoldBackgroundColor,
closedColor: Theme.of(context).colorScheme.background,
transitionType: ContainerTransitionType.fadeThrough,
transitionDuration: const Duration(milliseconds: 400),
useRootNavigator: true,
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:refilc_kreta_api/models/note.dart';
import 'package:refilc/utils/format.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'miss_tile.i18n.dart';
class MissTile extends StatelessWidget {
const MissTile(this.note, {super.key});
final Note note;
@override
Widget build(BuildContext context) {
return ListTile(
leading: Icon(_missIcon(),
color: Theme.of(context).colorScheme.secondary, size: 36.0),
visualDensity: VisualDensity.compact,
title: Text(
_missName(),
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
subtitle: Text(
note.content.split("órán nem volt")[0].capital(),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
);
}
IconData _missIcon() {
if (note.type?.name == "HaziFeladatHiany") {
return FeatherIcons.home;
} else if (note.type?.name == "Felszereleshiany") {
return FeatherIcons.book;
}
return FeatherIcons.slash;
}
String _missName() {
if (note.type?.name == "HaziFeladatHiany") {
return "Missing homework".i18n;
} else if (note.type?.name == "Felszereleshiany") {
return "Missing equipment".i18n;
}
return "?";
}
}

View File

@@ -0,0 +1,24 @@
import 'package:i18n_extension/i18n_extension.dart';
extension Localization on String {
static final _t = Translations.byLocale("hu_hu") +
{
"en_en": {
"Missing homework": "Missing homework",
"Missing equipment": "Missing equipment",
},
"hu_hu": {
"Missing homework": "Házi feladat hiány",
"Missing equipment": "Felszerelés Hiány",
},
"de_de": {
"Missing homework": "Fehlende Hausaufgaben",
"Missing equipment": "Fehlende Ausrüstung",
}
};
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,37 @@
import 'package:flutter/material.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/lesson.dart';
import 'package:refilc_mobile_ui/common/panel/panel_button.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'missed_exam_tile.i18n.dart';
class MissedExamTile extends StatelessWidget {
const MissedExamTile(this.missedExams, {super.key, this.onTap, this.padding});
final List<Lesson> missedExams;
final Function()? onTap;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0),
child: PanelButton(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6),
leading: SizedBox(
width: 36,
height: 36,
child: Icon(
FeatherIcons.slash,
color: AppColors.of(context).red.withOpacity(.75),
size: 28.0,
)),
title: Text("missed_exams"
.plural(missedExams.length)
.fill([missedExams.length])),
trailing: const Icon(FeatherIcons.arrowRight),
onPressed: onTap,
),
);
}
}

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,78 @@
import 'package:refilc/helpers/subject.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/theme/colors/colors.dart';
import 'package:refilc_kreta_api/models/lesson.dart';
import 'package:refilc_mobile_ui/common/bottom_sheet_menu/rounded_bottom_sheet.dart';
import 'package:refilc_mobile_ui/pages/timetable/timetable_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:refilc/utils/format.dart';
import 'package:provider/provider.dart';
import 'missed_exam_tile.i18n.dart';
class MissedExamView extends StatelessWidget {
const MissedExamView(this.missedExams, {super.key});
final List<Lesson> missedExams;
static show(List<Lesson> missedExams, {required BuildContext context}) =>
showRoundedModalBottomSheet(context, child: MissedExamView(missedExams));
@override
Widget build(BuildContext context) {
List<Widget> tiles = missedExams.map((e) => MissedExamViewTile(e)).toList();
return Column(children: tiles);
}
}
class MissedExamViewTile extends StatelessWidget {
const MissedExamViewTile(this.lesson, {super.key, this.padding});
final EdgeInsetsGeometry? padding;
final Lesson lesson;
@override
Widget build(BuildContext context) {
SettingsProvider settingsProvider = Provider.of<SettingsProvider>(context);
String? teacherName = lesson.teacher.isRenamed
? lesson.teacher.renamedTo
: lesson.teacher.name;
return Material(
type: MaterialType.transparency,
child: Padding(
padding: padding ??
const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: ListTile(
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)),
leading: Icon(
SubjectIcon.resolveVariant(
subject: lesson.subject, context: context),
color: AppColors.of(context).text.withOpacity(.8),
size: 32.0,
),
title: Text(
"${lesson.subject.renamedTo ?? lesson.subject.name.capital()}${lesson.date.format(context)}",
style: TextStyle(
fontWeight: FontWeight.w600,
fontStyle: lesson.subject.isRenamed &&
settingsProvider.renamedSubjectsItalics
? FontStyle.italic
: null),
),
subtitle: Text(
"missed_exam_contact".i18n.fill([teacherName ?? '']),
style: const TextStyle(fontWeight: FontWeight.w500),
),
trailing: const Icon(FeatherIcons.arrowRight),
onTap: () {
Navigator.of(context, rootNavigator: true).pop();
TimetablePage.jump(context, lesson: lesson);
},
),
),
);
}
}

View File

@@ -0,0 +1,18 @@
import 'package:refilc_kreta_api/models/lesson.dart';
import 'package:refilc_mobile_ui/common/widgets/missed_exam/missed_exam_tile.dart';
import 'package:refilc_mobile_ui/common/widgets/missed_exam/missed_exam_view.dart';
import 'package:flutter/material.dart';
class MissedExamViewable extends StatelessWidget {
const MissedExamViewable(this.missedExams, {super.key});
final List<Lesson> missedExams;
@override
Widget build(BuildContext context) {
return MissedExamTile(
missedExams,
onTap: () => MissedExamView.show(missedExams, context: context),
);
}
}

View File

@@ -0,0 +1,56 @@
import 'package:refilc_kreta_api/models/note.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_image.dart';
import 'package:flutter/material.dart';
class NoteTile extends StatelessWidget {
const NoteTile(this.note, {super.key, this.onTap, this.padding});
final Note note;
final void Function()? onTap;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
return Material(
type: MaterialType.transparency,
child: Padding(
padding: padding ?? const EdgeInsets.symmetric(horizontal: 8.0),
child: Theme(
data: Theme.of(context).copyWith(
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
),
child: ListTile(
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.only(left: 8.0, right: 12.0),
onTap: onTap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14.0)),
leading: ProfileImage(
isNotePfp: true,
name: (note.teacher.isRenamed
? note.teacher.renamedTo
: note.teacher.name) ??
'',
radius: 19.2,
backgroundColor: Theme.of(context).colorScheme.secondary,
),
title: Text(
note.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
note.content.replaceAll('\n', ' '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
minLeadingWidth: 0,
),
),
),
);
}
}

View File

@@ -0,0 +1,84 @@
import 'package:refilc/utils/color.dart';
import 'package:refilc_kreta_api/models/note.dart';
import 'package:refilc_mobile_ui/common/profile_image/profile_image.dart';
import 'package:refilc/utils/format.dart';
import 'package:refilc_mobile_ui/common/sliding_bottom_sheet.dart';
import 'package:flutter/material.dart';
import 'package:flutter_custom_tabs/flutter_custom_tabs.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
class NoteView extends StatelessWidget {
const NoteView(this.note, {super.key});
final Note note;
static void show(Note note, {required BuildContext context}) =>
showSlidingBottomSheet(context: context, child: NoteView(note));
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Header
ListTile(
leading: ProfileImage(
name: (note.teacher.isRenamed
? note.teacher.renamedTo
: note.teacher.name) ??
'',
radius: 22.0,
backgroundColor: ColorUtils.stringToColor(
(note.teacher.isRenamed
? note.teacher.renamedTo
: note.teacher.name) ??
'',
),
),
title: Text(
note.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: Text(
(note.teacher.isRenamed
? note.teacher.renamedTo
: note.teacher.name) ??
'',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w500),
),
trailing: Text(
note.date.format(context),
style: const TextStyle(fontWeight: FontWeight.w500),
),
),
// Details
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: SelectableLinkify(
text: note.content.escapeHtml(),
options: const LinkifyOptions(looseUrl: true, removeWww: true),
onOpen: (link) {
launch(link.url,
customTabsOption: CustomTabsOption(
toolbarColor: Theme.of(context).scaffoldBackgroundColor,
showPageTitle: true,
));
},
style: const TextStyle(fontWeight: FontWeight.w400),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,18 @@
import 'package:refilc_kreta_api/models/note.dart';
import 'package:refilc_mobile_ui/common/widgets/note/note_tile.dart';
import 'package:refilc_mobile_ui/common/widgets/note/note_view.dart';
import 'package:flutter/material.dart';
class NoteViewable extends StatelessWidget {
const NoteViewable(this.note, {super.key});
final Note note;
@override
Widget build(BuildContext context) {
return NoteTile(
note,
onTap: () => NoteView.show(note, context: context),
);
}
}

View File

@@ -0,0 +1,119 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:refilc/models/settings.dart';
import 'package:refilc/ui/widgets/grade/grade_tile.dart';
import 'package:flutter/material.dart';
import 'package:i18n_extension/i18n_widget.dart';
import 'package:provider/provider.dart';
class StatisticsTile extends StatelessWidget {
const StatisticsTile({
super.key,
required this.value,
this.title,
this.decimal = true,
this.color,
this.valueSuffix = '',
this.fill = false,
this.outline = false,
});
final double value;
final Widget? title;
final bool decimal;
final Color? color;
final String valueSuffix;
final bool fill;
final bool outline;
@override
Widget build(BuildContext context) {
String valueText;
if (decimal) {
valueText = value.toStringAsFixed(2);
} else {
valueText = value.toStringAsFixed(0);
}
if (I18n.of(context).locale.languageCode != "en") {
valueText = valueText.replaceAll(".", ",");
}
if (value.isNaN) {
valueText = "?";
}
return Container(
width: double.infinity,
padding: const EdgeInsets.all(18.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12.0),
color: Theme.of(context).colorScheme.background,
boxShadow: [
if (Provider.of<SettingsProvider>(context, listen: false)
.shadowEffect)
BoxShadow(
offset: const Offset(0, 21),
blurRadius: 23.0,
color: Theme.of(context).shadowColor,
)
],
),
constraints: const BoxConstraints(
minHeight: 140.0,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
if (title != null)
DefaultTextStyle(
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontWeight: FontWeight.w600,
fontSize: 18.0,
),
child: title!,
),
if (title != null) const SizedBox(height: 4.0),
Container(
margin: const EdgeInsets.only(top: 4.0),
padding:
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
decoration: BoxDecoration(
color: fill
? (color ?? gradeColor(context: context, value: value))
.withOpacity(.2)
: null,
border: outline || fill
? Border.all(
color:
(color ?? gradeColor(context: context, value: value))
.withOpacity(outline ? 1.0 : 0.0),
width: fill ? 5.0 : 5.0,
)
: null,
borderRadius: BorderRadius.circular(45.0),
),
child: AutoSizeText.rich(
TextSpan(
text: valueText,
children: [
if (valueSuffix != "")
TextSpan(
text: valueSuffix,
style: const TextStyle(fontSize: 24.0),
),
],
),
maxLines: 1,
minFontSize: 5,
textAlign: TextAlign.center,
style: TextStyle(
color: color ?? gradeColor(context: context, value: value),
fontWeight: FontWeight.w800,
fontSize: 28.0,
),
),
),
],
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More