diff --git a/filcnaplo_premium/README.md b/filcnaplo_premium/README.md new file mode 100644 index 0000000..979e950 --- /dev/null +++ b/filcnaplo_premium/README.md @@ -0,0 +1,3 @@ +# Premium ✨ + +A collection of features only accessible for premium subscribers. diff --git a/filcnaplo_premium/analysis_options.yaml b/filcnaplo_premium/analysis_options.yaml new file mode 100644 index 0000000..fd16f92 --- /dev/null +++ b/filcnaplo_premium/analysis_options.yaml @@ -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 diff --git a/filcnaplo_premium/android/database/DBManager.java b/filcnaplo_premium/android/database/DBManager.java new file mode 100644 index 0000000..34f4bd2 --- /dev/null +++ b/filcnaplo_premium/android/database/DBManager.java @@ -0,0 +1,119 @@ +package hu.filc.naplo.database; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; + +import java.sql.SQLException; + +import hu.filc.naplo.database.SQLiteHelper; + +public class DBManager { + private Context context; + private SQLiteDatabase database; + private SQLiteHelper dbHelper; + + public DBManager(Context c) { + this.context = c; + } + + public DBManager open() throws SQLException { + this.dbHelper = new SQLiteHelper(this.context); + this.database = this.dbHelper.getWritableDatabase(); + return this; + } + + public void close() { + this.dbHelper.close(); + } + + public Cursor fetchWidget(int wid) { + Cursor cursor = this.database.query(SQLiteHelper.TABLE_NAME_WIDGETS, new String[]{SQLiteHelper._ID, SQLiteHelper.DAY_SEL}, SQLiteHelper._ID + " = " + wid, null, null, null, null); + if (cursor != null) { + cursor.moveToFirst(); + } + return cursor; + } + + public Cursor fetchTimetable() { + Cursor cursor = this.database.query(SQLiteHelper.TABLE_NAME_USER_DATA, new String[]{SQLiteHelper.TIMETABLE}, null, null, null, null, null); + if (cursor != null) { + cursor.moveToFirst(); + } + return cursor; + } + + public Cursor fetchLastUser() { + Cursor cursor = this.database.query(SQLiteHelper.TABLE_NAME_SETTINGS, new String[]{SQLiteHelper.LAST_ACCOUNT_ID}, null, null, null, null, null); + if (cursor != null) { + cursor.moveToFirst(); + } + return cursor; + } + + public Cursor fetchTheme() { + Cursor cursor = this.database.query(SQLiteHelper.TABLE_NAME_SETTINGS, new String[]{SQLiteHelper.THEME, SQLiteHelper.ACCENT_COLOR}, null, null, null, null, null); + if (cursor != null) { + cursor.moveToFirst(); + } + return cursor; + } + + public Cursor fetchPremiumToken() { + Cursor cursor = this.database.query(SQLiteHelper.TABLE_NAME_SETTINGS, new String[]{SQLiteHelper.PREMIUM_TOKEN}, null, null, null, null, null); + if (cursor != null) { + cursor.moveToFirst(); + } + return cursor; + } + + public Cursor fetchPremiumScopes() { + Cursor cursor = this.database.query(SQLiteHelper.TABLE_NAME_SETTINGS, new String[]{SQLiteHelper.PREMIUM_SCOPES}, null, null, null, null, null); + if (cursor != null) { + cursor.moveToFirst(); + } + return cursor; + } + + public Cursor fetchLocale() { + Cursor cursor = this.database.query(SQLiteHelper.TABLE_NAME_SETTINGS, new String[]{SQLiteHelper.LOCALE}, null, null, null, null, null); + if (cursor != null) { + cursor.moveToFirst(); + } + return cursor; + } + + public void deleteWidget(int _id) { + this.database.delete(SQLiteHelper.TABLE_NAME_WIDGETS, "_id=" + _id, null); + } + + /*public void changeSettings(int _id, Map map) { + ContentValues con = new ContentValues(); + for(Map.Entry e: map.entrySet()){ + con.put(e.getKey(), e.getValue()); + } + this.database.update(SQLiteHelper.TABLE_NAME_WIDGETS, con, "_id = " + _id, null); + } + public void insertSettings(int _id, Map map) { + ContentValues con = new ContentValues(); + for(Map.Entry e: map.entrySet()){ + con.put(e.getKey(), e.getValue()); + //Log.d("Settings added", e.getKey() + " - " + e.getValue()); + } + this.database.insert(SQLiteHelper.TABLE_NAME_WIDGETS, null, con); + }*/ + + public void insertSelDay(int _id, int day_sel) { + ContentValues con = new ContentValues(); + con.put(SQLiteHelper._ID, _id); + con.put(SQLiteHelper.DAY_SEL, day_sel); + this.database.insert(SQLiteHelper.TABLE_NAME_WIDGETS, null, con); + } + + public int update(int _id, int day_sel) { + ContentValues con = new ContentValues(); + con.put(SQLiteHelper.DAY_SEL, day_sel); + return this.database.update(SQLiteHelper.TABLE_NAME_WIDGETS, con, SQLiteHelper._ID + " = " + _id, null); + } +} \ No newline at end of file diff --git a/filcnaplo_premium/android/database/SQLiteHelper.java b/filcnaplo_premium/android/database/SQLiteHelper.java new file mode 100644 index 0000000..e3e7147 --- /dev/null +++ b/filcnaplo_premium/android/database/SQLiteHelper.java @@ -0,0 +1,36 @@ +package hu.filc.naplo.database; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +public class SQLiteHelper extends SQLiteOpenHelper { + private static final String CREATE_TABLE_WIDGET = " create table widgets ( _id INTEGER NOT NULL, day_sel INTEGER NOT NULL);"; + private static final String DB_NAME = "app.db"; + private static final int DB_VERSION = 1; + public static final String _ID = "_id"; + public static final String DAY_SEL = "day_sel"; + public static final String TIMETABLE = "timetable"; + public static final String LAST_ACCOUNT_ID = "last_account_id"; + public static final String THEME = "theme"; + public static final String PREMIUM_TOKEN = "premium_token"; + public static final String PREMIUM_SCOPES = "premium_scopes"; + public static final String LOCALE = "language"; + public static final String ACCENT_COLOR = "accent_color"; + public static final String TABLE_NAME_WIDGETS = "widgets"; + public static final String TABLE_NAME_USER_DATA = "user_data"; + public static final String TABLE_NAME_SETTINGS = "settings"; + + public SQLiteHelper(Context context) { + super(context, DB_NAME, null, 7); + } + + public void onCreate(SQLiteDatabase db) { + db.execSQL(CREATE_TABLE_WIDGET); + } + + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + db.execSQL("DROP TABLE IF EXISTS widgets"); + onCreate(db); + } +} \ No newline at end of file diff --git a/filcnaplo_premium/android/local.properties b/filcnaplo_premium/android/local.properties new file mode 100644 index 0000000..4c14d41 --- /dev/null +++ b/filcnaplo_premium/android/local.properties @@ -0,0 +1,2 @@ +sdk.dir=/Users/unknown/Library/Android/sdk +flutter.sdk=/Users/unknown/flutter \ No newline at end of file diff --git a/filcnaplo_premium/android/utils/Utils.java b/filcnaplo_premium/android/utils/Utils.java new file mode 100644 index 0000000..e8dbfc3 --- /dev/null +++ b/filcnaplo_premium/android/utils/Utils.java @@ -0,0 +1,36 @@ +package hu.filc.naplo.utils; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; + +import java.util.Calendar; +import java.util.Date; + +public class Utils { + public static boolean hasNetwork(Context context) { + ConnectivityManager cm = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo netInfo = cm.getActiveNetworkInfo(); + if (netInfo != null && netInfo.isConnectedOrConnecting()) { + return true; + } + return false; + } + + public static Date getWeekStartDate() { + Calendar calendar = Calendar.getInstance(); + while (calendar.get(Calendar.DAY_OF_WEEK) != Calendar.MONDAY) { + calendar.add(Calendar.DATE, -1); + } + return calendar.getTime(); + } + + public static Date getWeekEndDate() { + Calendar calendar = Calendar.getInstance(); + while (calendar.get(Calendar.DAY_OF_WEEK) != Calendar.MONDAY) { + calendar.add(Calendar.DATE, 1); + } + calendar.add(Calendar.DATE, -1); + return calendar.getTime(); + } +} diff --git a/filcnaplo_premium/android/utils/Week.java b/filcnaplo_premium/android/utils/Week.java new file mode 100644 index 0000000..076c3ba --- /dev/null +++ b/filcnaplo_premium/android/utils/Week.java @@ -0,0 +1,65 @@ +package hu.filc.naplo.utils; + +import java.time.DayOfWeek; +import java.time.Duration; +import java.time.LocalDate; + +public class Week { + private final LocalDate start; + private final LocalDate end; + + private Week(LocalDate start, LocalDate end) { + this.start = start; + this.end = end; + } + + public static Week current() { + return fromDate(LocalDate.now()); + } + + public static Week fromId(int id) { + LocalDate _now = getYearStart().plusDays(id * 7L); + return new Week(_now.minusDays(_now.getDayOfWeek().getValue() - 1), _now.plusDays(7 - _now.getDayOfWeek().getValue())); + } + + public static Week fromDate(LocalDate date) { + + return new Week(date.minusDays(date.getDayOfWeek().getValue() - 1), date.plusDays(7 - date.getDayOfWeek().getValue())); + } + + public Week next() { + return Week.fromDate(start.plusDays(8)); + } + + public int id() { + return (int) Math.ceil(Duration.between(getYearStart().atStartOfDay(), start.atStartOfDay()).toDays() / 7f); + } + + private static LocalDate getYearStart() { + LocalDate now = LocalDate.now(); + LocalDate start = getYearStart(now.getYear()); + return start.isBefore(now) ? start : getYearStart(now.getYear() -1); + } + + private static LocalDate getYearStart(int year) { + LocalDate time = LocalDate.of(year, 9, 1); + if (time.getDayOfWeek() == DayOfWeek.SATURDAY) + return time.plusDays(2); + else if (time.getDayOfWeek() == DayOfWeek.SUNDAY) + return time.plusDays(1); + return time; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Week week = (Week) o; + return this.id() == week.id(); + } + + @Override + public int hashCode() { + return id(); + } +} \ No newline at end of file diff --git a/filcnaplo_premium/android/widget_timetable/WidgetTimetable.java b/filcnaplo_premium/android/widget_timetable/WidgetTimetable.java new file mode 100644 index 0000000..7bb33b4 --- /dev/null +++ b/filcnaplo_premium/android/widget_timetable/WidgetTimetable.java @@ -0,0 +1,397 @@ +package hu.filc.naplo.widget_timetable; + +import android.app.PendingIntent; +import android.appwidget.AppWidgetManager; +import android.appwidget.AppWidgetProvider; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.database.Cursor; +import android.net.Uri; +import android.os.Build; +import android.util.Log; +import android.view.View; +import android.widget.RemoteViews; +import android.widget.Toast; + +import org.joda.time.DateTime; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.time.DayOfWeek; +import java.time.format.TextStyle; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; + +import hu.filc.naplo.database.DBManager; +import hu.filc.naplo.MainActivity; +import hu.filc.naplo.R; + +import hu.filc.naplo.utils.Week; + +import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; + +import es.antonborri.home_widget.HomeWidgetBackgroundIntent; +import es.antonborri.home_widget.HomeWidgetLaunchIntent; +import es.antonborri.home_widget.HomeWidgetProvider; + +public class WidgetTimetable extends HomeWidgetProvider { + + private static final String ACTION_WIDGET_CLICK_NAV_LEFT = "list_widget.ACTION_WIDGET_CLICK_NAV_LEFT"; + private static final String ACTION_WIDGET_CLICK_NAV_RIGHT = "list_widget.ACTION_WIDGET_CLICK_NAV_RIGHT"; + private static final String ACTION_WIDGET_CLICK_NAV_TODAY = "list_widget.ACTION_WIDGET_CLICK_NAV_TODAY"; + private static final String ACTION_WIDGET_CLICK_NAV_REFRESH = "list_widget.ACTION_WIDGET_CLICK_NAV_REFRESH"; + private static final String ACTION_WIDGET_CLICK_BUY_PREMIUM = "list_widget.ACTION_WIDGET_CLICK_BUY_PREMIUM"; + + @Override + public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds, SharedPreferences widgetData) { + for (int i = 0; i < appWidgetIds.length; i++) { + RemoteViews views = generateView(context, appWidgetIds[i]); + + if(premiumEnabled(context) && userLoggedIn(context)) { + int rday = selectDay(context, appWidgetIds[i], 0, true); + views.setTextViewText(R.id.nav_current, convertDayOfWeek(context, rday)); + } + + pushUpdate(context, views, appWidgetIds[i]); + } + } + + public static void pushUpdate(Context context, RemoteViews remoteViews, int appWidgetSingleId) { + AppWidgetManager manager = AppWidgetManager.getInstance(context); + + manager.updateAppWidget(appWidgetSingleId, remoteViews); + manager.notifyAppWidgetViewDataChanged(appWidgetSingleId, R.id.widget_list); + } + + public static RemoteViews generateView(Context context, int appId) { + Intent serviceIntent = new Intent(context, WidgetTimetableService.class); + serviceIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appId); + serviceIntent.setData(Uri.parse(serviceIntent.toUri(Intent.URI_INTENT_SCHEME))); + + RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_timetable); + + views.setViewVisibility(R.id.need_premium, View.GONE); + views.setViewVisibility(R.id.need_login, View.GONE); + views.setViewVisibility(R.id.tt_grid_cont, View.GONE); + + if(!userLoggedIn(context)) { + views.setViewVisibility(R.id.need_login, View.VISIBLE); + views.setOnClickPendingIntent(R.id.open_login, makePending(context, ACTION_WIDGET_CLICK_BUY_PREMIUM, appId)); + } else if(premiumEnabled(context)) { + views.setViewVisibility(R.id.tt_grid_cont, View.VISIBLE); + views.setOnClickPendingIntent(R.id.nav_to_left, makePending(context, ACTION_WIDGET_CLICK_NAV_LEFT, appId)); + views.setOnClickPendingIntent(R.id.nav_to_right, makePending(context, ACTION_WIDGET_CLICK_NAV_RIGHT, appId)); + views.setOnClickPendingIntent(R.id.nav_current, makePending(context, ACTION_WIDGET_CLICK_NAV_TODAY, appId)); + views.setOnClickPendingIntent(R.id.nav_refresh, makePending(context, ACTION_WIDGET_CLICK_NAV_REFRESH, appId)); + views.setRemoteAdapter(R.id.widget_list, serviceIntent); + views.setEmptyView(R.id.widget_list, R.id.empty_view); + } else { + views.setViewVisibility(R.id.need_premium, View.VISIBLE); + views.setOnClickPendingIntent(R.id.buy_premium, makePending(context, ACTION_WIDGET_CLICK_BUY_PREMIUM, appId)); + } + + return views; + } + + static PendingIntent makePending(Context context, String action, int appWidgetId) { + Intent activebtnnext = new Intent(context, WidgetTimetable.class); + activebtnnext.setAction(action); + activebtnnext.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId); + return PendingIntent.getBroadcast(context, appWidgetId, activebtnnext , PendingIntent.FLAG_IMMUTABLE); + } + + @Override + public void onReceive(Context context, Intent intent) { + super.onReceive(context, intent); + + if(intent.hasExtra(AppWidgetManager.EXTRA_APPWIDGET_ID)) { + int appId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); + RemoteViews views = generateView(context, appId); + + try { + if(premiumEnabled(context) && userLoggedIn(context)) { + if (intent.getAction().equals(ACTION_WIDGET_CLICK_NAV_LEFT)) { + int rday = selectDay(context, appId, -1, false); + views.setTextViewText(R.id.nav_current, convertDayOfWeek(context, rday)); + + pushUpdate(context, views, appId); + } else if (intent.getAction().equals(ACTION_WIDGET_CLICK_NAV_RIGHT)) { + int rday = selectDay(context, appId, 1, false); + views.setTextViewText(R.id.nav_current, convertDayOfWeek(context, rday)); + + pushUpdate(context, views, appId); + } else if (intent.getAction().equals(ACTION_WIDGET_CLICK_NAV_TODAY)) { + int rday = getToday(context); + setSelectedDay(context, appId, rday); + + views.setTextViewText(R.id.nav_current, convertDayOfWeek(context, rday)); + + pushUpdate(context, views, appId); + } else if (intent.getAction().equals(ACTION_WIDGET_CLICK_NAV_REFRESH)) { + PendingIntent pendingIntent = HomeWidgetLaunchIntent.INSTANCE.getActivity(context, MainActivity.class, Uri.parse("timetable://refresh")); + pendingIntent.send(); + } else if (intent.getAction().equals("android.appwidget.action.APPWIDGET_DELETED")) { + DBManager dbManager = new DBManager(context.getApplicationContext()); + + try { + dbManager.open(); + dbManager.deleteWidget(appId); + dbManager.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + } + + if(intent.getAction().equals(ACTION_WIDGET_CLICK_BUY_PREMIUM)) { + PendingIntent pendingIntent = HomeWidgetLaunchIntent.INSTANCE.getActivity(context, MainActivity.class, Uri.parse("settings://premium")); + pendingIntent.send(); + } + } + catch (Exception e) { + e.printStackTrace(); + } + } + } + + public static String convertDayOfWeek(Context context, int rday) { + + /*if(rday == -1) return DayOfWeek.of(1).getDisplayName(TextStyle.FULL, new Locale("hu", "HU")); + + String dayOfWeek = DayOfWeek.of(rday + 1).getDisplayName(TextStyle.FULL, new Locale("hu", "HU"));*/ + + String dayOfWeek = "Unknown"; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Locale loc = getLocale(context); + + if (rday == -1) + return DayOfWeek.of(1).getDisplayName(TextStyle.FULL, loc); + + dayOfWeek = DayOfWeek.of(rday + 1).getDisplayName(TextStyle.FULL, loc); + } + + return dayOfWeek.substring(0, 1).toUpperCase() + dayOfWeek.substring(1).toLowerCase(); + } + + public static void setSelectedDay(Context context, int wid, int day) { + DBManager dbManager = new DBManager(context.getApplicationContext()); + + try { + dbManager.open(); + dbManager.update(wid, day); + dbManager.close(); + } catch (Exception e) { + e.printStackTrace(); + } + } + + public static int getToday(Context context) { + int rday = new DateTime().getDayOfWeek() - 1; + List s = genJsonDays(context); + + try { + if(checkIsAfter(s, rday)) rday += 1; + } catch (Exception e) { + e.printStackTrace(); + } + return retDay(rday, s.size()); + } + + public static int selectDay(Context context, int wid, int add, Boolean afterSubjects) { + DBManager dbManager = new DBManager(context.getApplicationContext()); + + try { + dbManager.open(); + Cursor cursor = dbManager.fetchWidget(wid); + + List s = genJsonDays(context); + int retday = new DateTime().getDayOfWeek() - 1; + + if(cursor.getCount() != 0) retday = retDay(cursor.getInt(1) + add, s.size()); + + if(afterSubjects) if(checkIsAfter(s, retday)) retday += 1; + retday = retDay(retday, s.size()); + + if(cursor.getCount() == 0) dbManager.insertSelDay(wid, retday); + else dbManager.update(wid, retday); + + dbManager.close(); + + return retday; + } catch (Exception e) { + e.printStackTrace(); + } + + return 0; + } + + public static Boolean checkIsAfter(List s, int retday) throws Exception { + retday = retDay(retday, s.size()); + + String vegIdopont = s.get(retday).getJSONObject(s.get(retday).length() - 1).getString("VegIdopont"); + + return new DateTime().isAfter(new DateTime(vegIdopont)); + } + + public static int retDay(int retday, int size) { + if (retday < 0) retday = size - 1; + else if (retday > size - 1) retday = 0; + + return retday; + } + + public static List genJsonDays(Context context) { + List gen_days = new ArrayList<>(); + + DBManager dbManager = new DBManager(context.getApplicationContext()); + try { + dbManager.open(); + Cursor ct = dbManager.fetchTimetable(); + dbManager.close(); + + if(ct.getCount() == 0) { + return gen_days; + } + + JSONObject fecthtt = new JSONObject(ct.getString(0)); + + JSONArray dayArray = new JSONArray(); + String currday = ""; + + String currweek = String.valueOf(Week.current().id()); + + JSONArray week = fecthtt.getJSONArray(currweek); + + for (int i=0; i < week.length(); i++) + { + try { + if(i == 0) currday = week.getJSONObject(0).getString("Datum"); + JSONObject oraObj = week.getJSONObject(i); + + if(!currday.equals(oraObj.getString("Datum"))) { + gen_days.add(dayArray); + currday = week.getJSONObject(i).getString("Datum"); + dayArray = new JSONArray(); + } + + dayArray.put(oraObj); + if(i == week.length() - 1) { + gen_days.add(dayArray); + } + } catch (Exception e) { + e.printStackTrace(); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + + Collections.sort(gen_days, new Comparator() { + + public int compare(JSONArray a, JSONArray b) { + long valA = 0; + long valB = 0; + + try { + valA = (long) new DateTime( a.getJSONObject(0).getString("Datum")).getMillis(); + valB = (long) new DateTime( b.getJSONObject(0).getString("Datum")).getMillis(); + } + catch (JSONException ignored) { + } + + return (int) (valA - valB); + } + }); + + return gen_days; + } + + public static String zeroPad(int value, int padding){ + StringBuilder b = new StringBuilder(); + b.append(value); + while(b.length() < padding){ + b.insert(0,"0"); + } + return b.toString(); + } + + public static Locale getLocale(Context context) { + DBManager dbManager = new DBManager(context.getApplicationContext()); + + try { + dbManager.open(); + String loc = dbManager.fetchLocale().getString(0); + dbManager.close(); + + if(loc.equals("hu") || loc.equals("de")) { + return new Locale(loc, loc.toUpperCase()); + } + } catch (Exception e) { + e.printStackTrace(); + } + + return new Locale("en", "GB"); + } + + public static boolean premiumEnabled(Context context) { + DBManager dbManager = new DBManager(context.getApplicationContext()); + + try { + dbManager.open(); + String premium_token = dbManager.fetchPremiumToken().getString(0); + String premium_scopes_raw = dbManager.fetchPremiumScopes().getString(0); + dbManager.close(); + + JSONArray arr = new JSONArray(premium_scopes_raw); + List premium_scopes = new ArrayList<>(); + for(int i = 0; i < arr.length(); i++){ + String scope = arr.getString(i); + premium_scopes.add(scope.substring(scope.lastIndexOf('.') + 1)); + } + + if(!premium_token.equals("") && (premium_scopes.contains("*") || premium_scopes.contains("TIMETALBE_WIDGET"))) { + return true; + } + } catch (Exception e) { + e.printStackTrace(); + } + + return false; + } + + public static boolean userLoggedIn(Context context) { + return !lastUserId(context).equals(""); + } + + public static String lastUserId(Context context) { + DBManager dbManager = new DBManager(context.getApplicationContext()); + try { + dbManager.open(); + Cursor cursor = dbManager.fetchLastUser(); + dbManager.close(); + + if(cursor != null && !cursor.getString(0).equals("")) { + String last_user = cursor.getString(0); + return last_user; + } + } catch (Exception e) { + e.printStackTrace(); + } + + return ""; + } + + @Override + public void onEnabled(Context context) { + } + + @Override + public void onDisabled(Context context) { + } +} \ No newline at end of file diff --git a/filcnaplo_premium/android/widget_timetable/WidgetTimetableDataProvider.java b/filcnaplo_premium/android/widget_timetable/WidgetTimetableDataProvider.java new file mode 100644 index 0000000..451ceab --- /dev/null +++ b/filcnaplo_premium/android/widget_timetable/WidgetTimetableDataProvider.java @@ -0,0 +1,354 @@ +package hu.filc.naplo.widget_timetable; + +import android.appwidget.AppWidgetManager; +import android.content.Context; +import android.content.Intent; +import android.database.Cursor; +import android.os.Build; +import android.util.Log; +import android.view.View; +import android.widget.RemoteViews; +import android.widget.RemoteViewsService; + +import org.joda.time.DateTime; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; + +import hu.filc.naplo.database.DBManager; +import hu.filc.naplo.R; + +public class WidgetTimetableDataProvider implements RemoteViewsService.RemoteViewsFactory { + + private Context context; + private int appWidgetId; + + private int rday = 0; + + private int theme; + + private Integer[] colorValues; + + List day_subjects = new ArrayList<>(); + List lessonIndexes = new ArrayList<>(); + + Item witem; + + /* Default values */ + + static class Item { + int Layout; + + int NumVisibility; + int NameVisibility; + int NameNodescVisibility; + int DescVisibility; + int RoomVisibility; + int TimeVisibility; + + int NumColor; + int NameColor; + int NameNodescColor; + int DescColor; + + Integer[] NameNodescPadding = {0, 0, 0, 0}; + + public Item(int Layout, int NumVisibility,int NameVisibility,int NameNodescVisibility,int DescVisibility,int RoomVisibility,int TimeVisibility,int NumColor,int NameColor,int NameNodescColor,int DescColor) { + this.Layout = Layout; + this.NumVisibility = NumVisibility; + this.NameVisibility = NameVisibility; + this.NameNodescVisibility = NameNodescVisibility; + this.DescVisibility = DescVisibility; + this.RoomVisibility = RoomVisibility; + this.TimeVisibility = TimeVisibility; + + this.NumColor = NumColor; + this.NameColor = NameColor; + this.NameNodescColor = NameNodescColor; + this.DescColor = DescColor; + } + } + + static class Lesson { + String status; + String lessonIndex; + String lessonName; + String lessonTopic; + String lessonRoom; + long lessonStart; + long lessonEnd; + String substituteTeacher; + + public Lesson(String status, String lessonIndex,String lessonName,String lessonTopic, String lessonRoom,long lessonStart,long lessonEnd,String substituteTeacher) { + this.status = status; + this.lessonIndex = lessonIndex; + this.lessonName = lessonName; + this.lessonTopic = lessonTopic; + this.lessonRoom = lessonRoom; + this.lessonStart = lessonStart; + this.lessonEnd = lessonEnd; + this.substituteTeacher = substituteTeacher; + } + } + + Integer[] itemNameNodescPadding = {0, 0, 0, 0}; + + public WidgetTimetableDataProvider(Context context, Intent intent) { + this.context = context; + this.appWidgetId = intent.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID); + + this.theme = getThemeAccent(context); + + this.colorValues = new Integer[]{R.color.filc, + R.color.blue_shade300, + R.color.green_shade300, + R.color.lime_shade300, + R.color.yellow_shade300, + R.color.orange_shade300, + R.color.red_shade300, + R.color.pink_shade300, + R.color.purple_shade300}; + + } + + @Override + public void onCreate() { + initData(); + } + + @Override + public void onDataSetChanged() { + initData(); + } + + @Override + public void onDestroy() { + + } + + @Override + public int getCount() { + + return day_subjects.size(); + } + + public void setLayout(final RemoteViews view) { + /* Visibilities */ + view.setViewVisibility(R.id.tt_item_num, witem.NumVisibility); + view.setViewVisibility(R.id.tt_item_name, witem.NameVisibility); + view.setViewVisibility(R.id.tt_item_name_nodesc, witem.NameNodescVisibility); + view.setViewVisibility(R.id.tt_item_desc, witem.DescVisibility); + view.setViewVisibility(R.id.tt_item_room, witem.RoomVisibility); + view.setViewVisibility(R.id.tt_item_time, witem.TimeVisibility); + + /* backgroundResources */ + view.setInt(R.id.main_lay, "setBackgroundResource", witem.Layout); + + /* Paddings */ + view.setViewPadding(R.id.tt_item_name_nodesc, witem.NameNodescPadding[0], witem.NameNodescPadding[1], witem.NameNodescPadding[2], witem.NameNodescPadding[3]); + + /* Text Colors */ + view.setInt(R.id.tt_item_num, "setTextColor", getColor(context, witem.NumColor)); + view.setInt(R.id.tt_item_name, "setTextColor", getColor(context, witem.NameColor)); + view.setInt(R.id.tt_item_name_nodesc, "setTextColor", getColor(context, witem.NameNodescColor)); + view.setInt(R.id.tt_item_desc, "setTextColor", getColor(context, witem.DescColor)); + } + + public int getColor(Context context, int color) { + return context.getResources().getColor(color); + } + + @Override + public RemoteViews getViewAt(int position) { + RemoteViews view = new RemoteViews(context.getPackageName(), R.layout.timetable_item); + + witem = defaultItem(theme); + + Lesson curr_subject = day_subjects.get(position); + + if (curr_subject.status.equals("empty")) { + witem.NumColor = R.color.text_miss_num; + + witem.TimeVisibility = View.GONE; + witem.RoomVisibility = View.GONE; + + witem.NameNodescColor = R.color.text_miss; + } + + if (!curr_subject.substituteTeacher.equals("null")) { + witem.NumColor = R.color.yellow; + witem.Layout = R.drawable.card_layout_tile_helyetesitett; + } + + if (curr_subject.status.equals("Elmaradt")) { + witem.NumColor = R.color.red; + witem.Layout = R.drawable.card_layout_tile_elmarad; + } else if (curr_subject.status.equals("TanevRendjeEsemeny")) { + witem.NumVisibility = View.GONE; + witem.TimeVisibility = View.GONE; + witem.RoomVisibility = View.GONE; + + witem.NameNodescPadding[0] = 50; + witem.NameNodescPadding[2] = 50; + + witem.NameNodescColor = R.color.text_miss; + } + + if (curr_subject.lessonTopic.equals("null")) { + witem.DescVisibility = View.GONE; + witem.NameVisibility = View.GONE; + + witem.NameNodescVisibility = View.VISIBLE; + } + + setLayout(view); + + String lessonIndexTrailing = curr_subject.lessonIndex.equals("+") ? "" : "."; + + view.setTextViewText(R.id.tt_item_num, curr_subject.lessonIndex + lessonIndexTrailing); + view.setTextViewText(R.id.tt_item_name, curr_subject.lessonName); + view.setTextViewText(R.id.tt_item_name_nodesc, curr_subject.lessonName); + view.setTextViewText(R.id.tt_item_desc, curr_subject.lessonTopic); + view.setTextViewText(R.id.tt_item_room, curr_subject.lessonRoom); + if(curr_subject.lessonStart != 0 && curr_subject.lessonEnd != 0) + view.setTextViewText(R.id.tt_item_time, WidgetTimetable.zeroPad(new DateTime(curr_subject.lessonStart).getHourOfDay(), 2) + ":" + WidgetTimetable.zeroPad(new DateTime(curr_subject.lessonStart).getMinuteOfHour(), 2) + + "\n" + WidgetTimetable.zeroPad(new DateTime(curr_subject.lessonEnd).getHourOfDay(), 2) + ":" + WidgetTimetable.zeroPad(new DateTime(curr_subject.lessonEnd).getMinuteOfHour(),2)); + + return view; + } + + @Override + public RemoteViews getLoadingView() { + return null; + } + + @Override + public int getViewTypeCount() { + return 1; + } + + @Override + public long getItemId(int position) { + return position; + } + + @Override + public boolean hasStableIds() { + return true; + } + + private void initData() { + + theme = getThemeAccent(context); + + rday = WidgetTimetable.selectDay(context, appWidgetId, 0, false); + + day_subjects.clear(); + lessonIndexes.clear(); + + try { + List arr = WidgetTimetable.genJsonDays(context); + + if(arr.isEmpty()) { + return; + } + JSONArray arr_lessons = WidgetTimetable.genJsonDays(context).get(rday); + + for (int i = 0; i < arr_lessons.length(); i++) { + JSONObject obj_lessons = arr_lessons.getJSONObject(i); + + day_subjects.add(jsonToLesson(obj_lessons)); + } + } catch (JSONException e) { + e.printStackTrace(); + } + + if(day_subjects.size() > 0) { + Collections.sort(day_subjects, new Comparator() { + public int compare(Lesson o1, Lesson o2) { + return new DateTime(o1.lessonStart).compareTo(new DateTime(o2.lessonStart)); + } + }); + + for (int i = 0; i < day_subjects.size(); i++) { + if(!day_subjects.get(i).lessonIndex.equals("+")) { + lessonIndexes.add(Integer.valueOf(day_subjects.get(i).lessonIndex)); + } + } + + if(lessonIndexes.size() > 0) { + + int lessonsChecked = Collections.min(lessonIndexes); + int i = 0; + + while(lessonsChecked < Collections.max(lessonIndexes)) { + if(!lessonIndexes.contains(lessonsChecked)) { + day_subjects.add(i, emptyLesson(lessonsChecked)); + } + lessonsChecked++; + i++; + } + } + } + } + + public static Integer getThemeAccent(Context context) { + DBManager dbManager = new DBManager(context.getApplicationContext()); + + try { + dbManager.open(); + Cursor cursor = dbManager.fetchTheme(); + dbManager.close(); + + return cursor.getInt(1); + } catch (Exception e) { + e.printStackTrace(); + } + + return 0; + } + + public Item defaultItem(int theme) { + return new Item( + R.drawable.card_layout_tile, + View.VISIBLE, + View.VISIBLE, + View.INVISIBLE, + View.VISIBLE, + View.VISIBLE, + View.VISIBLE, + colorValues[theme >= colorValues.length ? 0 : theme], + R.color.text, + R.color.text, + R.color.text_desc + ); + } + + public Lesson emptyLesson(int lessonIndex) { + return new Lesson("empty", String.valueOf(lessonIndex), "Lyukasóra", "null", "null", 0, 0, "null"); + } + + public Lesson jsonToLesson(JSONObject json) { + try { + return new Lesson( + json.getJSONObject("Allapot").getString("Nev"), + !json.getString("Oraszam").equals("null") ? json.getString("Oraszam") : "+", + json.getString("Nev"), + json.getString("Tema"), + json.getString("TeremNeve"), + new DateTime(json.getString("KezdetIdopont")).getMillis(), + new DateTime(json.getString("VegIdopont")).getMillis(), + json.getString("HelyettesTanarNeve") + ); + }catch (Exception e) { + Log.d("Filc", "exception: " + e); + }; + + return null; + } +} \ No newline at end of file diff --git a/filcnaplo_premium/android/widget_timetable/WidgetTimetableService.java b/filcnaplo_premium/android/widget_timetable/WidgetTimetableService.java new file mode 100644 index 0000000..40adb41 --- /dev/null +++ b/filcnaplo_premium/android/widget_timetable/WidgetTimetableService.java @@ -0,0 +1,12 @@ +package hu.filc.naplo.widget_timetable; + +import android.content.Intent; +import android.os.Build; +import android.widget.RemoteViewsService; + +public class WidgetTimetableService extends RemoteViewsService { + @Override + public RemoteViewsFactory onGetViewFactory(Intent intent) { + return new WidgetTimetableDataProvider(getApplicationContext(), intent); + } +} diff --git a/filcnaplo_premium/lib/api/auth.dart b/filcnaplo_premium/lib/api/auth.dart new file mode 100644 index 0000000..e3d5c5f --- /dev/null +++ b/filcnaplo_premium/lib/api/auth.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:filcnaplo/api/client.dart'; +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo_premium/models/premium_result.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:uni_links/uni_links.dart'; +import 'package:http/http.dart' as http; +import 'package:home_widget/home_widget.dart'; + +class PremiumAuth { + final SettingsProvider _settings; + StreamSubscription? _sub; + + PremiumAuth({required SettingsProvider settings}) : _settings = settings; + + initAuth() { + try { + _sub ??= uriLinkStream.listen( + (Uri? uri) { + if (uri != null) { + final accessToken = uri.queryParameters['access_token']; + if (accessToken != null) { + finishAuth(accessToken); + } + } + }, + onError: (err) { + log("ERROR: initAuth: $err"); + }, + ); + + launchUrl( + Uri.parse("https://api.filcnaplo.hu/oauth"), + mode: LaunchMode.externalApplication, + ); + } catch (err, sta) { + log("ERROR: initAuth: $err\n$sta"); + } + } + + Future finishAuth(String accessToken) async { + try { + // final res = await http.get(Uri.parse("${FilcAPI.premiumScopesApi}?access_token=${Uri.encodeComponent(accessToken)}")); + // final scopes = ((jsonDecode(res.body) as Map)["scopes"] as List).cast(); + // log("[INFO] Premium auth finish: ${scopes.join(',')}"); + await _settings.update(premiumAccessToken: accessToken); + final result = await refreshAuth(); + if (Platform.isAndroid) updateWidget(); + return result; + } catch (err, sta) { + log("[ERROR] Premium auth failed: $err\n$sta"); + } + + await _settings.update(premiumAccessToken: "", premiumScopes: []); + if (Platform.isAndroid) updateWidget(); + return false; + } + + Future updateWidget() async { + try { + return HomeWidget.updateWidget(name: 'widget_timetable.WidgetTimetable'); + } on PlatformException catch (exception) { + if (kDebugMode) { + print('Error Updating Widget After Auth. $exception'); + } + } + return false; + } + + Future refreshAuth({bool removePremium = false}) async { + if (!removePremium) { + if (_settings.premiumAccessToken == "") { + await _settings.update(premiumScopes: [], premiumLogin: ""); + return false; + } + + // Skip premium check when disconnected + try { + final status = await InternetAddress.lookup('github.com'); + if (status.isEmpty) return false; + } on SocketException catch (_) { + return false; + } + + for (int tries = 0; tries < 3; tries++) { + try { + final res = await http.post(Uri.parse(FilcAPI.premiumApi), body: { + "access_token": _settings.premiumAccessToken, + }); + + if (res.body == "") throw "empty body"; + + final premium = PremiumResult.fromJson(jsonDecode(res.body) as Map); + // Activation succeeded + log("[INFO] Premium activated: ${premium.scopes.join(',')}"); + await _settings.update( + premiumAccessToken: premium.accessToken, + premiumScopes: premium.scopes, + premiumLogin: premium.login, + ); + return true; + } catch (err, sta) { + log("[ERROR] Premium activation failed: $err\n$sta"); + } + + await Future.delayed(const Duration(seconds: 1)); + } + } + + // Activation failed + await _settings.update(premiumAccessToken: "", premiumScopes: [], premiumLogin: ""); + return false; + } +} diff --git a/filcnaplo_premium/lib/models/premium_result.dart b/filcnaplo_premium/lib/models/premium_result.dart new file mode 100644 index 0000000..d86aa16 --- /dev/null +++ b/filcnaplo_premium/lib/models/premium_result.dart @@ -0,0 +1,19 @@ +class PremiumResult { + final String accessToken; + final List scopes; + final String login; + + PremiumResult({ + required this.accessToken, + required this.scopes, + required this.login, + }); + + factory PremiumResult.fromJson(Map json) { + return PremiumResult( + accessToken: json["access_token"] ?? "", + scopes: (json["scopes"] ?? []).cast(), + login: json["login"], + ); + } +} diff --git a/filcnaplo_premium/lib/models/premium_scopes.dart b/filcnaplo_premium/lib/models/premium_scopes.dart new file mode 100644 index 0000000..2642a9d --- /dev/null +++ b/filcnaplo_premium/lib/models/premium_scopes.dart @@ -0,0 +1,32 @@ +class PremiumScopes { + /// VIP + static const all = "filc.premium.*"; + + /// Kupak + + /// Custom nickname + static const nickname = "filc.premium.NICKNAME"; + + /// Advanced grade statistics + static const gradeStats = "filc.premium.GRADE_STATS"; + + /// Advanced theme customization + static const customColors = "filc.premium.CUSTOM_COLORS"; + + /// Icon pack customization for subjects + static const customIcons = "filc.premium.CUSTOM_ICONS"; + + /// Modify subject names + static const renameSubjects = "filc.premium.RENAME_SUBJECTS"; + + /// Tinta + + /// Timetable homescreen widget + static const timetableWidget = "filc.premium.TIMETALBE_WIDGET"; + + /// Goal Planner + static const goalPlanner = "filc.premium.GOAL_PLANNER"; + + /// Fullscreen weekly timetable view + static const fsTimetable = "filc.premium.FS_TIMETABLE"; +} diff --git a/filcnaplo_premium/lib/providers/premium_provider.dart b/filcnaplo_premium/lib/providers/premium_provider.dart new file mode 100644 index 0000000..980afd4 --- /dev/null +++ b/filcnaplo_premium/lib/providers/premium_provider.dart @@ -0,0 +1,28 @@ +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo_premium/api/auth.dart'; +import 'package:filcnaplo_premium/models/premium_scopes.dart'; +import 'package:flutter/widgets.dart'; + +class PremiumProvider extends ChangeNotifier { + final SettingsProvider _settings; + List get scopes => _settings.premiumScopes; + bool hasScope(String scope) => scopes.contains(scope) || scopes.contains(PremiumScopes.all); + String get accessToken => _settings.premiumAccessToken; + String get login => _settings.premiumLogin; + bool get hasPremium => _settings.premiumAccessToken != "" && _settings.premiumScopes.isNotEmpty; + + late final PremiumAuth _auth; + PremiumAuth get auth => _auth; + + PremiumProvider({required SettingsProvider settings}) : _settings = settings { + _auth = PremiumAuth(settings: _settings); + _settings.addListener(() { + notifyListeners(); + }); + } + + Future activate({bool removePremium = false}) async { + await _auth.refreshAuth(removePremium: removePremium); + notifyListeners(); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/block_picker.dart b/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/block_picker.dart new file mode 100644 index 0000000..35cfc9a --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/block_picker.dart @@ -0,0 +1,137 @@ +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker + +/// Blocky Color Picker + +library block_colorpicker; + +import 'package:flutter/material.dart'; +import 'package:filcnaplo/theme/colors/accent.dart'; +import 'utils.dart'; + +/// Child widget for layout builder. +typedef PickerItem = Widget Function(Color color); + +/// Customize the layout. +typedef PickerLayoutBuilder = Widget Function(BuildContext context, List colors, PickerItem child); + +/// Customize the item shape. +typedef PickerItemBuilder = Widget Function(Color color, bool isCurrentColor, void Function() changeColor); + +// Provide a list of colors for block color picker. +// const List _defaultColors = [ +// Colors.red, +// Colors.pink, +// Colors.purple, +// Colors.deepPurple, +// Colors.indigo, +// Colors.blue, +// Colors.lightBlue, +// Colors.cyan, +// Colors.teal, +// Colors.green, +// Colors.lightGreen, +// Colors.lime, +// Colors.yellow, +// Colors.amber, +// Colors.orange, +// Colors.deepOrange, +// Colors.brown, +// Colors.grey, +// Colors.blueGrey, +// Colors.black, +// ]; + +// Provide a layout for [BlockPicker]. +Widget _defaultLayoutBuilder(BuildContext context, List colors, PickerItem child) { + Orientation orientation = MediaQuery.of(context).orientation; + + return SizedBox( + width: 300, + height: orientation == Orientation.portrait ? 360 : 200, + child: GridView.count( + crossAxisCount: orientation == Orientation.portrait ? 4 : 6, + crossAxisSpacing: 5, + mainAxisSpacing: 5, + children: [for (Color color in colors) child(color)], + ), + ); +} + +// Provide a shape for [BlockPicker]. +Widget _defaultItemBuilder(Color color, bool isCurrentColor, void Function() changeColor) { + return Container( + margin: const EdgeInsets.all(7), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + boxShadow: [BoxShadow(color: color.withOpacity(0.8), offset: const Offset(1, 2), blurRadius: 5)], + ), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: changeColor, + borderRadius: BorderRadius.circular(50), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 210), + opacity: isCurrentColor ? 1 : 0, + child: Icon(Icons.done, color: useWhiteForeground(color) ? Colors.white : Colors.black), + ), + ), + ), + ); +} + +// The blocky color picker you can alter the layout and shape. +class BlockPicker extends StatefulWidget { + BlockPicker({ + Key? key, + required this.pickerColor, + required this.onColorChanged, + this.useInShowDialog = true, + this.layoutBuilder = _defaultLayoutBuilder, + this.itemBuilder = _defaultItemBuilder, + }) : super(key: key); + + final Color? pickerColor; + final ValueChanged onColorChanged; + final List availableColors = accentColorMap.values.toList(); + final bool useInShowDialog; + final PickerLayoutBuilder layoutBuilder; + final PickerItemBuilder itemBuilder; + + @override + State createState() => _BlockPickerState(); +} + +class _BlockPickerState extends State { + Color? _currentColor; + + @override + void initState() { + _currentColor = widget.pickerColor; + super.initState(); + } + + void changeColor(Color color) { + setState(() => _currentColor = color); + widget.onColorChanged(color); + } + + @override + Widget build(BuildContext context) { + return widget.layoutBuilder( + context, + widget.availableColors, + (Color color) => widget.itemBuilder( + color, + (_currentColor != null && (widget.useInShowDialog ? true : widget.pickerColor != null)) + ? (_currentColor?.value == color.value) && (widget.useInShowDialog ? true : widget.pickerColor?.value == color.value) + : false, + () => changeColor(color), + ), + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/colorpicker.dart b/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/colorpicker.dart new file mode 100644 index 0000000..e17fbf5 --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/colorpicker.dart @@ -0,0 +1,348 @@ +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker + +/// HSV(HSB)/HSL Color Picker example +/// +/// You can create your own layout by importing `picker.dart`. + +library hsv_picker; + +import 'package:filcnaplo_premium/ui/mobile/flutter_colorpicker/block_picker.dart'; +import 'package:filcnaplo_premium/ui/mobile/flutter_colorpicker/palette.dart'; +import 'package:filcnaplo_premium/ui/mobile/flutter_colorpicker/utils.dart'; +import 'package:filcnaplo_premium/ui/mobile/settings/theme.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/custom_switch.dart'; + +class FilcColorPicker extends StatefulWidget { + const FilcColorPicker({ + Key? key, + required this.colorMode, + required this.pickerColor, + required this.onColorChanged, + required this.onColorChangeEnd, + this.pickerHsvColor, + this.onHsvColorChanged, + this.paletteType = PaletteType.hsvWithHue, + this.enableAlpha = true, + @Deprecated('Use empty list in [labelTypes] to disable label.') this.showLabel = true, + this.labelTypes = const [ColorLabelType.rgb, ColorLabelType.hsv, ColorLabelType.hsl], + @Deprecated('Use Theme.of(context).textTheme.bodyText1 & 2 to alter text style.') this.labelTextStyle, + this.displayThumbColor = false, + this.portraitOnly = false, + this.colorPickerWidth = 300.0, + this.pickerAreaHeightPercent = 1.0, + this.pickerAreaBorderRadius = const BorderRadius.all(Radius.zero), + this.hexInputBar = false, + this.hexInputController, + this.colorHistory, + this.onHistoryChanged, + }) : super(key: key); + + final CustomColorMode colorMode; + final Color pickerColor; + final ValueChanged onColorChanged; + final void Function(Color color, {bool? adaptive}) onColorChangeEnd; + final HSVColor? pickerHsvColor; + final ValueChanged? onHsvColorChanged; + final PaletteType paletteType; + final bool enableAlpha; + final bool showLabel; + final List labelTypes; + final TextStyle? labelTextStyle; + final bool displayThumbColor; + final bool portraitOnly; + final double colorPickerWidth; + final double pickerAreaHeightPercent; + final BorderRadius pickerAreaBorderRadius; + final bool hexInputBar; + final TextEditingController? hexInputController; + final List? colorHistory; + final ValueChanged>? onHistoryChanged; + + @override + _FilcColorPickerState createState() => _FilcColorPickerState(); +} + +class _FilcColorPickerState extends State { + HSVColor currentHsvColor = const HSVColor.fromAHSV(0.0, 0.0, 0.0, 0.0); + List colorHistory = []; + bool isAdvancedView = false; + + @override + void initState() { + currentHsvColor = (widget.pickerHsvColor != null) ? widget.pickerHsvColor as HSVColor : HSVColor.fromColor(widget.pickerColor); + // If there's no initial text in `hexInputController`, + if (widget.hexInputController?.text.isEmpty == true) { + // set it to the current's color HEX value. + widget.hexInputController?.text = colorToHex( + currentHsvColor.toColor(), + enableAlpha: widget.enableAlpha, + ); + } + // Listen to the text input, If there is an `hexInputController` provided. + widget.hexInputController?.addListener(colorPickerTextInputListener); + if (widget.colorHistory != null && widget.onHistoryChanged != null) { + colorHistory = widget.colorHistory ?? []; + } + super.initState(); + } + + @override + void didUpdateWidget(FilcColorPicker oldWidget) { + super.didUpdateWidget(oldWidget); + currentHsvColor = (widget.pickerHsvColor != null) ? widget.pickerHsvColor as HSVColor : HSVColor.fromColor(widget.pickerColor); + } + + void colorPickerTextInputListener() { + // It can't be null really, since it's only listening if the controller + // is provided, but it may help to calm the Dart analyzer in the future. + if (widget.hexInputController == null) return; + // If a user is inserting/typing any text — try to get the color value from it, + // and interpret its transparency, dependent on the widget's settings. + final Color? color = colorFromHex(widget.hexInputController!.text, enableAlpha: widget.enableAlpha); + // If it's the valid color: + if (color != null) { + // set it as the current color and + setState(() => currentHsvColor = HSVColor.fromColor(color)); + // notify with a callback. + widget.onColorChanged(color); + if (widget.onHsvColorChanged != null) widget.onHsvColorChanged!(currentHsvColor); + } + } + + @override + void dispose() { + widget.hexInputController?.removeListener(colorPickerTextInputListener); + super.dispose(); + } + + Widget colorPickerSlider(TrackType trackType) { + return ColorPickerSlider( + trackType, + currentHsvColor, + (HSVColor color) { + // Update text in `hexInputController` if provided. + widget.hexInputController?.text = colorToHex(color.toColor(), enableAlpha: widget.enableAlpha); + setState(() => currentHsvColor = color); + widget.onColorChanged(currentHsvColor.toColor()); + if (widget.onHsvColorChanged != null) widget.onHsvColorChanged!(currentHsvColor); + }, + () => widget.onColorChangeEnd(currentHsvColor.toColor()), + (p) { + ScaffoldMessenger.of(context).clearSnackBars(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text("Move the ${p == 0 ? 'Saturation (second)' : 'Value (third)'} slider first.", + textAlign: TextAlign.center, style: TextStyle(color: AppColors.of(context).text, fontWeight: FontWeight.w600)), + backgroundColor: AppColors.of(context).background)); + }, + displayThumbColor: widget.displayThumbColor, + ); + } + + void onColorChanging(HSVColor color) { + // Update text in `hexInputController` if provided. + widget.hexInputController?.text = colorToHex(color.toColor(), enableAlpha: widget.enableAlpha); + setState(() => currentHsvColor = color); + widget.onColorChanged(currentHsvColor.toColor()); + if (widget.onHsvColorChanged != null) widget.onHsvColorChanged!(currentHsvColor); + } + + @override + Widget build(BuildContext context) { + if (MediaQuery.of(context).orientation == Orientation.portrait || widget.portraitOnly) { + return Column( + children: [ + if (widget.colorMode != CustomColorMode.theme) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(left: 12.0, right: 12.0), + child: SizedBox( + height: 45.0, + width: double.infinity, + child: colorPickerSlider(TrackType.hue), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 12.0, right: 12.0), + child: SizedBox( + height: 45.0, + width: double.infinity, + child: colorPickerSlider(TrackType.saturation), + ), + ), + if (isAdvancedView) + Padding( + padding: const EdgeInsets.only(left: 12.0, right: 12.0), + child: SizedBox( + height: 45.0, + width: double.infinity, + child: colorPickerSlider(TrackType.value), + ), + ), + ], + ), + ), + if (isAdvancedView && widget.colorMode != CustomColorMode.theme) + Padding( + padding: const EdgeInsets.only(bottom: 6.0), + child: ColorPickerInput( + currentHsvColor.toColor(), + (Color color) { + setState(() => currentHsvColor = HSVColor.fromColor(color)); + widget.onColorChanged(currentHsvColor.toColor()); + if (widget.onHsvColorChanged != null) widget.onHsvColorChanged!(currentHsvColor); + }, + enableAlpha: false, + embeddedText: false, + ), + ), + SizedBox( + height: 70 * (widget.colorMode == CustomColorMode.theme ? 2 : 1), + child: BlockPicker( + pickerColor: Colors.red, + layoutBuilder: (context, colors, child) { + return GridView.count( + shrinkWrap: true, + crossAxisCount: widget.colorMode == CustomColorMode.theme ? 2 : 1, + scrollDirection: Axis.horizontal, + crossAxisSpacing: 15, + physics: const BouncingScrollPhysics(), + mainAxisSpacing: 15, + padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0), + children: List.generate(colors.toSet().length + (widget.colorMode == CustomColorMode.theme ? 1 : 0), (index) { + if (widget.colorMode == CustomColorMode.theme) { + if (index == 0) { + return GestureDetector( + onTap: () => widget.onColorChangeEnd(Colors.transparent, adaptive: true), + child: ColorIndicator(HSVColor.fromColor(const Color.fromARGB(255, 255, 238, 177)), + icon: CupertinoIcons.wand_stars, currentHsvColor: currentHsvColor, width: 30, height: 30, adaptive: true), + ); + } + index--; + } + return GestureDetector( + onTap: () => widget.onColorChangeEnd(colors[index]), + child: ColorIndicator(HSVColor.fromColor(colors[index]), currentHsvColor: currentHsvColor, width: 30, height: 30), + ); + }), + ); + }, + onColorChanged: (c) => {}, + ), + ), + if (widget.colorMode != CustomColorMode.theme) + Material( + color: Colors.transparent, + child: InkWell( + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + onTap: () => setState(() { + isAdvancedView = !isAdvancedView; + }), + child: Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + CustomSwitch( + onChanged: (v) => setState(() => isAdvancedView = v), + value: isAdvancedView, + ), + const SizedBox(width: 12.0), + Text( + "Advanced", + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16.0, + color: AppColors.of(context).text.withOpacity(isAdvancedView ? 1.0 : .5), + ), + ), + ], + ), + ), + ), + ), + ], + ); + } else { + return Row( + children: [ + //SizedBox(width: widget.colorPickerWidth, height: widget.colorPickerWidth * widget.pickerAreaHeightPercent, child: colorPicker()), + Column( + children: [ + Row( + children: [ + const SizedBox(width: 20.0), + GestureDetector( + onTap: () => setState(() { + if (widget.onHistoryChanged != null && !colorHistory.contains(currentHsvColor.toColor())) { + colorHistory.add(currentHsvColor.toColor()); + widget.onHistoryChanged!(colorHistory); + } + }), + child: ColorIndicator(currentHsvColor), + ), + Column( + children: [ + //SizedBox(height: 40.0, width: 260.0, child: sliderByPaletteType()), + if (widget.enableAlpha) SizedBox(height: 40.0, width: 260.0, child: colorPickerSlider(TrackType.alpha)), + ], + ), + const SizedBox(width: 10.0), + ], + ), + if (colorHistory.isNotEmpty) + SizedBox( + width: widget.colorPickerWidth, + height: 50, + child: ListView(scrollDirection: Axis.horizontal, children: [ + for (Color color in colorHistory) + Padding( + key: Key(color.hashCode.toString()), + padding: const EdgeInsets.fromLTRB(15, 18, 0, 0), + child: Center( + child: GestureDetector( + onTap: () => onColorChanging(HSVColor.fromColor(color)), + onLongPress: () { + if (colorHistory.remove(color)) { + widget.onHistoryChanged!(colorHistory); + setState(() {}); + } + }, + child: ColorIndicator(HSVColor.fromColor(color), width: 30, height: 30), + ), + ), + ), + const SizedBox(width: 15), + ]), + ), + const SizedBox(height: 20.0), + if (widget.hexInputBar) + ColorPickerInput( + currentHsvColor.toColor(), + (Color color) { + setState(() => currentHsvColor = HSVColor.fromColor(color)); + widget.onColorChanged(currentHsvColor.toColor()); + if (widget.onHsvColorChanged != null) widget.onHsvColorChanged!(currentHsvColor); + }, + enableAlpha: widget.enableAlpha, + embeddedText: false, + ), + const SizedBox(height: 5), + ], + ), + ], + ); + } + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/colors.dart b/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/colors.dart new file mode 100644 index 0000000..289cf20 --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/colors.dart @@ -0,0 +1,174 @@ +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker + +import 'dart:ui'; + +/// X11 Colors +/// +/// https://en.wikipedia.org/wiki/X11_color_names + +const Map x11Colors = { + 'aliceblue': Color(0xfff0f8ff), + 'antiquewhite': Color(0xfffaebd7), + 'aqua': Color(0xff00ffff), + 'aquamarine': Color(0xff7fffd4), + 'azure': Color(0xfff0ffff), + 'beige': Color(0xfff5f5dc), + 'bisque': Color(0xffffe4c4), + 'black': Color(0xff000000), + 'blanchedalmond': Color(0xffffebcd), + 'blue': Color(0xff0000ff), + 'blueviolet': Color(0xff8a2be2), + 'brown': Color(0xffa52a2a), + 'burlywood': Color(0xffdeb887), + 'cadetblue': Color(0xff5f9ea0), + 'chartreuse': Color(0xff7fff00), + 'chocolate': Color(0xffd2691e), + 'coral': Color(0xffff7f50), + 'cornflower': Color(0xff6495ed), + 'cornflowerblue': Color(0xff6495ed), + 'cornsilk': Color(0xfffff8dc), + 'crimson': Color(0xffdc143c), + 'cyan': Color(0xff00ffff), + 'darkblue': Color(0xff00008b), + 'darkcyan': Color(0xff008b8b), + 'darkgoldenrod': Color(0xffb8860b), + 'darkgray': Color(0xffa9a9a9), + 'darkgreen': Color(0xff006400), + 'darkgrey': Color(0xffa9a9a9), + 'darkkhaki': Color(0xffbdb76b), + 'darkmagenta': Color(0xff8b008b), + 'darkolivegreen': Color(0xff556b2f), + 'darkorange': Color(0xffff8c00), + 'darkorchid': Color(0xff9932cc), + 'darkred': Color(0xff8b0000), + 'darksalmon': Color(0xffe9967a), + 'darkseagreen': Color(0xff8fbc8f), + 'darkslateblue': Color(0xff483d8b), + 'darkslategray': Color(0xff2f4f4f), + 'darkslategrey': Color(0xff2f4f4f), + 'darkturquoise': Color(0xff00ced1), + 'darkviolet': Color(0xff9400d3), + 'deeppink': Color(0xffff1493), + 'deepskyblue': Color(0xff00bfff), + 'dimgray': Color(0xff696969), + 'dimgrey': Color(0xff696969), + 'dodgerblue': Color(0xff1e90ff), + 'firebrick': Color(0xffb22222), + 'floralwhite': Color(0xfffffaf0), + 'forestgreen': Color(0xff228b22), + 'fuchsia': Color(0xffff00ff), + 'gainsboro': Color(0xffdcdcdc), + 'ghostwhite': Color(0xfff8f8ff), + 'gold': Color(0xffffd700), + 'goldenrod': Color(0xffdaa520), + 'gray': Color(0xff808080), + 'green': Color(0xff008000), + 'greenyellow': Color(0xffadff2f), + 'grey': Color(0xff808080), + 'honeydew': Color(0xfff0fff0), + 'hotpink': Color(0xffff69b4), + 'indianred': Color(0xffcd5c5c), + 'indigo': Color(0xff4b0082), + 'ivory': Color(0xfffffff0), + 'khaki': Color(0xfff0e68c), + 'laserlemon': Color(0xffffff54), + 'lavender': Color(0xffe6e6fa), + 'lavenderblush': Color(0xfffff0f5), + 'lawngreen': Color(0xff7cfc00), + 'lemonchiffon': Color(0xfffffacd), + 'lightblue': Color(0xffadd8e6), + 'lightcoral': Color(0xfff08080), + 'lightcyan': Color(0xffe0ffff), + 'lightgoldenrod': Color(0xfffafad2), + 'lightgoldenrodyellow': Color(0xfffafad2), + 'lightgray': Color(0xffd3d3d3), + 'lightgreen': Color(0xff90ee90), + 'lightgrey': Color(0xffd3d3d3), + 'lightpink': Color(0xffffb6c1), + 'lightsalmon': Color(0xffffa07a), + 'lightseagreen': Color(0xff20b2aa), + 'lightskyblue': Color(0xff87cefa), + 'lightslategray': Color(0xff778899), + 'lightslategrey': Color(0xff778899), + 'lightsteelblue': Color(0xffb0c4de), + 'lightyellow': Color(0xffffffe0), + 'lime': Color(0xff00ff00), + 'limegreen': Color(0xff32cd32), + 'linen': Color(0xfffaf0e6), + 'magenta': Color(0xffff00ff), + 'maroon': Color(0xff800000), + 'maroon2': Color(0xff7f0000), + 'maroon3': Color(0xffb03060), + 'mediumaquamarine': Color(0xff66cdaa), + 'mediumblue': Color(0xff0000cd), + 'mediumorchid': Color(0xffba55d3), + 'mediumpurple': Color(0xff9370db), + 'mediumseagreen': Color(0xff3cb371), + 'mediumslateblue': Color(0xff7b68ee), + 'mediumspringgreen': Color(0xff00fa9a), + 'mediumturquoise': Color(0xff48d1cc), + 'mediumvioletred': Color(0xffc71585), + 'midnightblue': Color(0xff191970), + 'mintcream': Color(0xfff5fffa), + 'mistyrose': Color(0xffffe4e1), + 'moccasin': Color(0xffffe4b5), + 'navajowhite': Color(0xffffdead), + 'navy': Color(0xff000080), + 'oldlace': Color(0xfffdf5e6), + 'olive': Color(0xff808000), + 'olivedrab': Color(0xff6b8e23), + 'orange': Color(0xffffa500), + 'orangered': Color(0xffff4500), + 'orchid': Color(0xffda70d6), + 'palegoldenrod': Color(0xffeee8aa), + 'palegreen': Color(0xff98fb98), + 'paleturquoise': Color(0xffafeeee), + 'palevioletred': Color(0xffdb7093), + 'papayawhip': Color(0xffffefd5), + 'peachpuff': Color(0xffffdab9), + 'peru': Color(0xffcd853f), + 'pink': Color(0xffffc0cb), + 'plum': Color(0xffdda0dd), + 'powderblue': Color(0xffb0e0e6), + 'purple': Color(0xff800080), + 'purple2': Color(0xff7f007f), + 'purple3': Color(0xffa020f0), + 'rebeccapurple': Color(0xff663399), + 'red': Color(0xffff0000), + 'rosybrown': Color(0xffbc8f8f), + 'royalblue': Color(0xff4169e1), + 'saddlebrown': Color(0xff8b4513), + 'salmon': Color(0xfffa8072), + 'sandybrown': Color(0xfff4a460), + 'seagreen': Color(0xff2e8b57), + 'seashell': Color(0xfffff5ee), + 'sienna': Color(0xffa0522d), + 'silver': Color(0xffc0c0c0), + 'skyblue': Color(0xff87ceeb), + 'slateblue': Color(0xff6a5acd), + 'slategray': Color(0xff708090), + 'slategrey': Color(0xff708090), + 'snow': Color(0xfffffafa), + 'springgreen': Color(0xff00ff7f), + 'steelblue': Color(0xff4682b4), + 'tan': Color(0xffd2b48c), + 'teal': Color(0xff008080), + 'thistle': Color(0xffd8bfd8), + 'tomato': Color(0xffff6347), + 'turquoise': Color(0xff40e0d0), + 'violet': Color(0xffee82ee), + 'wheat': Color(0xfff5deb3), + 'white': Color(0xffffffff), + 'whitesmoke': Color(0xfff5f5f5), + 'yellow': Color(0xffffff00), + 'yellowgreen': Color(0xff9acd32), +}; + +Color? colorFromName(String val) => x11Colors[val.trim().replaceAll(' ', '').toLowerCase()]; + +extension ColorExtension on String { + Color? toColor() => colorFromName(this); +} diff --git a/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/palette.dart b/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/palette.dart new file mode 100644 index 0000000..fe4b066 --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/palette.dart @@ -0,0 +1,785 @@ +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker + +/// The components of HSV Color Picker +/// +/// Try to create a Color Picker with other layout on your own :) + +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo/theme/colors/accent.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'utils.dart'; + +/// Palette types for color picker area widget. +enum PaletteType { + hsv, + hsvWithHue, + hsvWithValue, + hsvWithSaturation, + hsl, + hslWithHue, + hslWithLightness, + hslWithSaturation, + rgbWithBlue, + rgbWithGreen, + rgbWithRed, + hueWheel, +} + +/// Track types for slider picker. +enum TrackType { + hue, + saturation, + saturationForHSL, + value, + lightness, + red, + green, + blue, + alpha, +} + +enum FilcTrackType { + hue, + saturation, + value, +} + +/// Color information label type. +enum ColorLabelType { hex, rgb, hsv, hsl } + +/// Types for slider picker widget. +enum ColorModel { rgb, hsv, hsl } +// enum ColorSpace { rgb, hsv, hsl, hsp, okhsv, okhsl, xyz, yuv, lab, lch, cmyk } + +/// Painter for SV mixture. +class HSVWithHueColorPainter extends CustomPainter { + const HSVWithHueColorPainter(this.hsvColor, {this.pointerColor}); + + final HSVColor hsvColor; + final Color? pointerColor; + + @override + void paint(Canvas canvas, Size size) { + final Rect rect = Offset.zero & size; + const Gradient gradientV = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.white, Colors.black], + ); + final Gradient gradientH = LinearGradient( + colors: [ + Colors.white, + HSVColor.fromAHSV(1.0, hsvColor.hue, 1.0, 1.0).toColor(), + ], + ); + canvas.drawRect(rect, Paint()..shader = gradientV.createShader(rect)); + canvas.drawRect( + rect, + Paint() + ..blendMode = BlendMode.multiply + ..shader = gradientH.createShader(rect), + ); + + canvas.drawCircle( + Offset(size.width * hsvColor.saturation, size.height * (1 - hsvColor.value)), + size.height * 0.04, + Paint() + ..color = pointerColor ?? (useWhiteForeground(hsvColor.toColor()) ? Colors.white : Colors.black) + ..strokeWidth = 1.5 + ..style = PaintingStyle.stroke, + ); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} + +class _SliderLayout extends MultiChildLayoutDelegate { + static const String track = 'track'; + static const String thumb = 'thumb'; + static const String gestureContainer = 'gesturecontainer'; + + @override + void performLayout(Size size) { + layoutChild( + track, + BoxConstraints.tightFor( + width: size.width + 3, + height: size.height / 1.5, + ), + ); + positionChild(track, const Offset(-2.0, 0)); + layoutChild( + thumb, + const BoxConstraints.tightFor(width: 5.5, height: 10.5), + ); + positionChild(thumb, Offset(0.0, (size.height / 1.5) / 2 - 4.5)); + layoutChild( + gestureContainer, + BoxConstraints.tightFor(width: size.width, height: size.height), + ); + positionChild(gestureContainer, Offset.zero); + } + + @override + bool shouldRelayout(_SliderLayout oldDelegate) => false; +} + +/// Painter for all kinds of track types. +class TrackPainter extends CustomPainter { + const TrackPainter(this.trackType, this.hsvColor); + + final TrackType trackType; + final HSVColor hsvColor; + + @override + void paint(Canvas canvas, Size size) { + final Rect rect = Offset.zero & size; + if (trackType == TrackType.alpha) { + final Size chessSize = Size(size.height / 2, size.height / 2); + Paint chessPaintB = Paint()..color = const Color(0xffcccccc); + Paint chessPaintW = Paint()..color = Colors.white; + List.generate((size.height / chessSize.height).round(), (int y) { + List.generate((size.width / chessSize.width).round(), (int x) { + canvas.drawRect( + Offset(chessSize.width * x, chessSize.width * y) & chessSize, + (x + y) % 2 != 0 ? chessPaintW : chessPaintB, + ); + }); + }); + } + + switch (trackType) { + case TrackType.hue: + final List colors = [ + const HSVColor.fromAHSV(1.0, 0.0, 1.0, 1.0).toColor(), + const HSVColor.fromAHSV(1.0, 60.0, 1.0, 1.0).toColor(), + const HSVColor.fromAHSV(1.0, 120.0, 1.0, 1.0).toColor(), + const HSVColor.fromAHSV(1.0, 180.0, 1.0, 1.0).toColor(), + const HSVColor.fromAHSV(1.0, 240.0, 1.0, 1.0).toColor(), + const HSVColor.fromAHSV(1.0, 300.0, 1.0, 1.0).toColor(), + const HSVColor.fromAHSV(1.0, 360.0, 1.0, 1.0).toColor(), + ]; + Gradient gradient = LinearGradient(colors: colors); + canvas.drawRect(rect, Paint()..shader = gradient.createShader(rect)); + break; + case TrackType.saturation: + final List colors = [ + HSVColor.fromAHSV(1.0, hsvColor.hue, 0.0, 1.0).toColor(), + HSVColor.fromAHSV(1.0, hsvColor.hue, 1.0, 1.0).toColor(), + ]; + Gradient gradient = LinearGradient(colors: colors); + canvas.drawRect(rect, Paint()..shader = gradient.createShader(rect)); + break; + case TrackType.saturationForHSL: + final List colors = [ + HSLColor.fromAHSL(1.0, hsvColor.hue, 0.0, 0.5).toColor(), + HSLColor.fromAHSL(1.0, hsvColor.hue, 1.0, 0.5).toColor(), + ]; + Gradient gradient = LinearGradient(colors: colors); + canvas.drawRect(rect, Paint()..shader = gradient.createShader(rect)); + break; + case TrackType.value: + final List colors = [ + HSVColor.fromAHSV(1.0, hsvColor.hue, 1.0, 0.0).toColor(), + HSVColor.fromAHSV(1.0, hsvColor.hue, 1.0, 1.0).toColor(), + ]; + Gradient gradient = LinearGradient(colors: colors); + canvas.drawRect(rect, Paint()..shader = gradient.createShader(rect)); + break; + case TrackType.lightness: + final List colors = [ + HSLColor.fromAHSL(1.0, hsvColor.hue, 1.0, 0.0).toColor(), + HSLColor.fromAHSL(1.0, hsvColor.hue, 1.0, 0.5).toColor(), + HSLColor.fromAHSL(1.0, hsvColor.hue, 1.0, 1.0).toColor(), + ]; + Gradient gradient = LinearGradient(colors: colors); + canvas.drawRect(rect, Paint()..shader = gradient.createShader(rect)); + break; + case TrackType.red: + final List colors = [ + hsvColor.toColor().withRed(0).withOpacity(1.0), + hsvColor.toColor().withRed(255).withOpacity(1.0), + ]; + Gradient gradient = LinearGradient(colors: colors); + canvas.drawRect(rect, Paint()..shader = gradient.createShader(rect)); + break; + case TrackType.green: + final List colors = [ + hsvColor.toColor().withGreen(0).withOpacity(1.0), + hsvColor.toColor().withGreen(255).withOpacity(1.0), + ]; + Gradient gradient = LinearGradient(colors: colors); + canvas.drawRect(rect, Paint()..shader = gradient.createShader(rect)); + break; + case TrackType.blue: + final List colors = [ + hsvColor.toColor().withBlue(0).withOpacity(1.0), + hsvColor.toColor().withBlue(255).withOpacity(1.0), + ]; + Gradient gradient = LinearGradient(colors: colors); + canvas.drawRect(rect, Paint()..shader = gradient.createShader(rect)); + break; + case TrackType.alpha: + final List colors = [ + hsvColor.toColor().withOpacity(0.0), + hsvColor.toColor().withOpacity(1.0), + ]; + Gradient gradient = LinearGradient(colors: colors); + canvas.drawRect(rect, Paint()..shader = gradient.createShader(rect)); + break; + } + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} + +/// Painter for thumb of slider. +class ThumbPainter extends CustomPainter { + const ThumbPainter({this.thumbColor, this.fullThumbColor = false}); + + final Color? thumbColor; + final bool fullThumbColor; + + @override + void paint(Canvas canvas, Size size) { + canvas.drawShadow( + Path() + ..addOval( + Rect.fromCircle(center: const Offset(0.5, 2.0), radius: size.width * 1.8), + ), + Colors.black, + 3.0, + true, + ); + canvas.drawCircle( + Offset(0.0, size.height * 0.4), + size.height, + Paint() + ..color = Colors.white + ..style = PaintingStyle.fill); + if (thumbColor != null) { + canvas.drawCircle( + Offset(0.0, size.height * 0.4), + size.height * (fullThumbColor ? 1.0 : 0.65), + Paint() + ..color = thumbColor! + ..style = PaintingStyle.fill); + } + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} + +/// Painter for chess type alpha background in color indicator widget. +class IndicatorPainter extends CustomPainter { + const IndicatorPainter(this.color); + + final Color color; + + @override + void paint(Canvas canvas, Size size) { + final Size chessSize = Size(size.width / 10, size.height / 10); + final Paint chessPaintB = Paint()..color = const Color(0xFFCCCCCC); + final Paint chessPaintW = Paint()..color = Colors.white; + List.generate((size.height / chessSize.height).round(), (int y) { + List.generate((size.width / chessSize.width).round(), (int x) { + canvas.drawRect( + Offset(chessSize.width * x, chessSize.height * y) & chessSize, + (x + y) % 2 != 0 ? chessPaintW : chessPaintB, + ); + }); + }); + + canvas.drawCircle( + Offset(size.width / 2, size.height / 2), + size.height / 2, + Paint() + ..color = color + ..style = PaintingStyle.fill); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) => false; +} + +/// Provide hex input wiget for 3/6/8 digits. +class ColorPickerInput extends StatefulWidget { + const ColorPickerInput( + this.color, + this.onColorChanged, { + Key? key, + this.enableAlpha = true, + this.embeddedText = false, + this.disable = false, + }) : super(key: key); + + final Color color; + final ValueChanged onColorChanged; + final bool enableAlpha; + final bool embeddedText; + final bool disable; + + @override + _ColorPickerInputState createState() => _ColorPickerInputState(); +} + +class _ColorPickerInputState extends State { + TextEditingController textEditingController = TextEditingController(); + int inputColor = 0; + + @override + void dispose() { + textEditingController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (inputColor != widget.color.value) { + textEditingController.text = '#' + + widget.color.red.toRadixString(16).toUpperCase().padLeft(2, '0') + + widget.color.green.toRadixString(16).toUpperCase().padLeft(2, '0') + + widget.color.blue.toRadixString(16).toUpperCase().padLeft(2, '0') + + (widget.enableAlpha ? widget.color.alpha.toRadixString(16).toUpperCase().padLeft(2, '0') : ''); + } + return Padding( + padding: const EdgeInsets.only(top: 6.0, left: 12.0, right: 12.0), + child: SizedBox( + width: double.infinity, + child: TextField( + enabled: !widget.disable, + controller: textEditingController, + style: TextStyle( + fontSize: 18, + color: Theme.of(context).colorScheme.onBackground, + ), + inputFormatters: [ + UpperCaseTextFormatter(), + FilteringTextInputFormatter.allow(RegExp(kValidHexPattern)), + ], + decoration: InputDecoration( + isDense: true, + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.0), + borderSide: const BorderSide(color: Colors.transparent, width: 0.0), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.0), + borderSide: const BorderSide(color: Colors.transparent, width: 0.0), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12.0), + borderSide: const BorderSide(color: Colors.transparent, width: 0.0), + ), + contentPadding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 8.0), + fillColor: AppColors.of(context).text.withOpacity(.1), + ), + onChanged: (String value) { + String input = value; + if (value.length == 9) { + input = value.split('').getRange(7, 9).join() + value.split('').getRange(1, 7).join(); + } + final Color? color = colorFromHex(input); + if (color != null) { + widget.onColorChanged(color); + inputColor = color.value; + } + }, + ), + ), + ); + } +} + +/*class ValueColorPickerSlider extends StatefulWidget { + ValueColorPickerSlider(this.trackType, this.initialHsvColor, this.onProgressChanged, this.onColorChangeEnd, {Key? key}) : super(key: key); + + final TrackType trackType; + final HSVColor initialHsvColor; + final void Function(double progress) onProgressChanged; + final void Function() onColorChangeEnd; + + @override + State createState() => _ValueColorPickerSliderState(); +} + +class _ValueColorPickerSliderState extends State { + HSVColor hsvColor = HSVColor.fromColor(Colors.red); + + @override + void initState() { + super.initState(); + hsvColor = widget.initialHsvColor; + } + + void slideEvent(RenderBox getBox, BoxConstraints box, Offset globalPosition) { + double localDx = getBox.globalToLocal(globalPosition).dx - 15.0; + double progress = localDx.clamp(0.0, box.maxWidth - 30.0) / (box.maxWidth - 30.0); + setState(() { + switch (widget.trackType) { + case TrackType.hue: + hsvColor = hsvColor.withHue(progress * 359); + break; + case TrackType.saturation: + hsvColor = hsvColor.withSaturation(progress); + break; + case TrackType.value: + hsvColor = hsvColor.withValue(progress); + break; + default: + break; + } + }); + widget.onProgressChanged(progress); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (BuildContext context, BoxConstraints box) { + double thumbOffset = 15.0; + Color thumbColor = Colors.white; + switch (widget.trackType) { + case TrackType.hue: + thumbOffset += (box.maxWidth - 30.0) * hsvColor.hue / 360; + break; + case TrackType.saturation: + thumbOffset += (box.maxWidth - 30.0) * hsvColor.saturation; + break; + case TrackType.value: + thumbOffset += (box.maxWidth - 30.0) * hsvColor.value; + break; + default: + break; + } + + return CustomMultiChildLayout( + delegate: _SliderLayout(), + children: [ + LayoutId( + id: _SliderLayout.track, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(50.0)), + child: CustomPaint( + painter: TrackPainter( + TrackType.values.firstWhere((element) => element == widget.trackType), + hsvColor, + )), + ), + ), + LayoutId( + id: _SliderLayout.thumb, + child: Transform.translate( + offset: Offset(thumbOffset, 0.0), + child: CustomPaint( + painter: ThumbPainter( + thumbColor: thumbColor, + fullThumbColor: false, + ), + ), + ), + ), + LayoutId( + id: _SliderLayout.gestureContainer, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints box) { + RenderBox? getBox = context.findRenderObject() as RenderBox?; + return GestureDetector( + onPanDown: (DragDownDetails details) => getBox != null ? slideEvent(getBox, box, details.globalPosition) : null, + onPanEnd: (details) => widget.onColorChangeEnd(), + onPanUpdate: (DragUpdateDetails details) => getBox != null ? slideEvent(getBox, box, details.globalPosition) : null, + ); + }, + ), + ), + ], + ); + }); + } +}*/ + +/// 9 track types for slider picker widget. +class ColorPickerSlider extends StatelessWidget { + const ColorPickerSlider( + this.trackType, + this.hsvColor, + this.onColorChanged, + this.onColorChangeEnd, + this.onProblem, { + Key? key, + this.displayThumbColor = false, + this.fullThumbColor = false, + }) : super(key: key); + + final TrackType trackType; + final HSVColor hsvColor; + final ValueChanged onColorChanged; + final void Function() onColorChangeEnd; + final void Function(int v) onProblem; + final bool displayThumbColor; + final bool fullThumbColor; + + void slideEvent(RenderBox getBox, BoxConstraints box, Offset globalPosition) { + double localDx = getBox.globalToLocal(globalPosition).dx - 15.0; + double progress = localDx.clamp(0.0, box.maxWidth - 30.0) / (box.maxWidth - 30.0); + switch (trackType) { + case TrackType.hue: + // 360 is the same as zero + // if set to 360, sliding to end goes to zero + final newColor = hsvColor.withHue(progress * 359); + if (newColor.saturation == 0) { + onProblem(0); + return; + } + onColorChanged(newColor); + break; + case TrackType.saturation: + final newColor = hsvColor.withSaturation(progress); + if (newColor.value == 0) { + onProblem(1); + return; + } + onColorChanged(newColor); + break; + case TrackType.value: + onColorChanged(hsvColor.withValue(progress)); + break; + default: + break; + } + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder(builder: (BuildContext context, BoxConstraints box) { + double thumbOffset = 15.0; + Color thumbColor; + switch (trackType) { + case TrackType.hue: + thumbOffset += (box.maxWidth - 30.0) * hsvColor.hue / 360; + thumbColor = HSVColor.fromAHSV(1.0, hsvColor.hue, 1.0, 1.0).toColor(); + break; + case TrackType.saturation: + thumbOffset += (box.maxWidth - 30.0) * hsvColor.saturation; + thumbColor = HSVColor.fromAHSV(1.0, hsvColor.hue, hsvColor.saturation, 1.0).toColor(); + break; + case TrackType.saturationForHSL: + thumbOffset += (box.maxWidth - 30.0) * hsvToHsl(hsvColor).saturation; + thumbColor = HSLColor.fromAHSL(1.0, hsvColor.hue, hsvToHsl(hsvColor).saturation, 0.5).toColor(); + break; + case TrackType.value: + thumbOffset += (box.maxWidth - 30.0) * hsvColor.value; + thumbColor = HSVColor.fromAHSV(1.0, hsvColor.hue, 1.0, hsvColor.value).toColor(); + break; + case TrackType.lightness: + thumbOffset += (box.maxWidth - 30.0) * hsvToHsl(hsvColor).lightness; + thumbColor = HSLColor.fromAHSL(1.0, hsvColor.hue, 1.0, hsvToHsl(hsvColor).lightness).toColor(); + break; + case TrackType.red: + thumbOffset += (box.maxWidth - 30.0) * hsvColor.toColor().red / 0xff; + thumbColor = hsvColor.toColor().withOpacity(1.0); + break; + case TrackType.green: + thumbOffset += (box.maxWidth - 30.0) * hsvColor.toColor().green / 0xff; + thumbColor = hsvColor.toColor().withOpacity(1.0); + break; + case TrackType.blue: + thumbOffset += (box.maxWidth - 30.0) * hsvColor.toColor().blue / 0xff; + thumbColor = hsvColor.toColor().withOpacity(1.0); + break; + case TrackType.alpha: + thumbOffset += (box.maxWidth - 30.0) * hsvColor.toColor().opacity; + thumbColor = hsvColor.toColor().withOpacity(hsvColor.alpha); + break; + } + + return CustomMultiChildLayout( + delegate: _SliderLayout(), + children: [ + LayoutId( + id: _SliderLayout.track, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(50.0)), + child: CustomPaint( + painter: TrackPainter( + trackType, + hsvColor, + )), + ), + ), + LayoutId( + id: _SliderLayout.thumb, + child: Transform.translate( + offset: Offset(thumbOffset, 0.0), + child: CustomPaint( + painter: ThumbPainter( + thumbColor: displayThumbColor ? thumbColor : null, + fullThumbColor: fullThumbColor, + ), + ), + ), + ), + LayoutId( + id: _SliderLayout.gestureContainer, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints box) { + RenderBox? getBox = context.findRenderObject() as RenderBox?; + return GestureDetector( + onPanDown: (DragDownDetails details) => getBox != null ? slideEvent(getBox, box, details.globalPosition) : null, + onPanEnd: (details) { + if ((trackType == TrackType.hue && hsvColor.saturation == 0) || (trackType == TrackType.saturation && hsvColor.value == 0)) { + return; + } + onColorChangeEnd(); + }, + onPanUpdate: (DragUpdateDetails details) => getBox != null ? slideEvent(getBox, box, details.globalPosition) : null, + ); + }, + ), + ), + ], + ); + }); + } +} + +/// Simple round color indicator. +class ColorIndicator extends StatelessWidget { + const ColorIndicator( + this.hsvColor, { + Key? key, + this.currentHsvColor, + this.icon, + this.width = 50.0, + this.height = 50.0, + this.adaptive = false, + }) : super(key: key); + + final HSVColor hsvColor; + final HSVColor? currentHsvColor; + final double width; + final double height; + final IconData? icon; + final bool adaptive; + + @override + Widget build(BuildContext context) { + Color color = hsvColor.toColor(); + + return Container( + width: width, + height: height, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + boxShadow: [ + BoxShadow( + color: useWhiteForeground(color) ? Colors.white.withOpacity(.5) : Colors.black.withOpacity(.5), + offset: const Offset(0, 0), + blurRadius: 5) + ], + ), + child: Material( + color: Colors.transparent, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 210), + opacity: (icon != null || currentHsvColor == hsvColor) && + (adaptive || Provider.of(context, listen: false).accentColor != AccentColor.adaptive) + ? 1 + : 0, + child: Icon(icon ?? Icons.done, color: useWhiteForeground(color) ? Colors.white : Colors.black), + ), + ), + ); + } +} + +/// Provide Rectangle & Circle 2 categories, 10 variations of palette widget. +class ColorPickerArea extends StatelessWidget { + const ColorPickerArea( + this.hsvColor, + this.onColorChanged, + this.onChangeEnd, + this.paletteType, { + Key? key, + }) : super(key: key); + + final HSVColor hsvColor; + final ValueChanged onColorChanged; + final void Function() onChangeEnd; + final PaletteType paletteType; + + /*void _handleColorRectChange(double horizontal, double vertical) { + onColorChanged(hsvColor.withSaturation(horizontal).withValue(vertical)); + }*/ + + void _handleGesture(Offset position, BuildContext context, double height, double width) { + RenderBox? getBox = context.findRenderObject() as RenderBox?; + if (getBox == null) return; + + Offset localOffset = getBox.globalToLocal(position); + double horizontal = localOffset.dx.clamp(0.0, width); + double vertical = localOffset.dy.clamp(0.0, height); + + //_handleColorRectChange(horizontal / width, 1 - vertical / height); + + onColorChanged(hsvColor.withSaturation(horizontal).withValue(vertical)); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + double width = constraints.maxWidth; + double height = constraints.maxHeight; + + return RawGestureDetector( + gestures: { + _AlwaysWinPanGestureRecognizer: GestureRecognizerFactoryWithHandlers<_AlwaysWinPanGestureRecognizer>( + () => _AlwaysWinPanGestureRecognizer(), + (_AlwaysWinPanGestureRecognizer instance) { + instance + ..onDown = ((details) => _handleGesture(details.globalPosition, context, height, width)) + ..onEnd = ((d) => onChangeEnd()) + ..onUpdate = ((details) => _handleGesture(details.globalPosition, context, height, width)); + }, + ), + }, + child: Builder( + builder: (BuildContext _) { + return CustomPaint(painter: HSVWithHueColorPainter(hsvColor)); + }, + ), + ); + }, + ); + } +} + +class _AlwaysWinPanGestureRecognizer extends PanGestureRecognizer { + @override + void addAllowedPointer(event) { + super.addAllowedPointer(event); + resolve(GestureDisposition.accepted); + } + + @override + String get debugDescription => 'alwaysWin'; +} + +/// Uppercase text formater +class UpperCaseTextFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate(oldValue, TextEditingValue newValue) => + TextEditingValue(text: newValue.text.toUpperCase(), selection: newValue.selection); +} diff --git a/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/utils.dart b/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/utils.dart new file mode 100644 index 0000000..515ae1f --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/flutter_colorpicker/utils.dart @@ -0,0 +1,220 @@ +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker +// FROM: https://pub.dev/packages/flutter_colorpicker + +/// Common function lib + +import 'dart:math'; +import 'package:flutter/painting.dart'; +import 'colors.dart'; + +/// Check if is good condition to use white foreground color by passing +/// the background color, and optional bias. +/// +/// Reference: +/// +/// Old: https://www.w3.org/TR/WCAG20-TECHS/G18.html +/// +/// New: https://github.com/mchome/flutter_statusbarcolor/issues/40 +bool useWhiteForeground(Color backgroundColor, {double bias = 0.0}) { + // Old: + // return 1.05 / (color.computeLuminance() + 0.05) > 4.5; + + // New: + int v = sqrt(pow(backgroundColor.red, 2) * 0.299 + + pow(backgroundColor.green, 2) * 0.587 + + pow(backgroundColor.blue, 2) * 0.114) + .round(); + return v < 130 + bias ? true : false; +} + +/// Convert HSV to HSL +/// +/// Reference: https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_HSL +HSLColor hsvToHsl(HSVColor color) { + double s = 0.0; + double l = 0.0; + l = (2 - color.saturation) * color.value / 2; + if (l != 0) { + if (l == 1) { + s = 0.0; + } else if (l < 0.5) { + s = color.saturation * color.value / (l * 2); + } else { + s = color.saturation * color.value / (2 - l * 2); + } + } + return HSLColor.fromAHSL( + color.alpha, + color.hue, + s.clamp(0.0, 1.0), + l.clamp(0.0, 1.0), + ); +} + +/// Convert HSL to HSV +/// +/// Reference: https://en.wikipedia.org/wiki/HSL_and_HSV#HSL_to_HSV +HSVColor hslToHsv(HSLColor color) { + double s = 0.0; + double v = 0.0; + + v = color.lightness + color.saturation * (color.lightness < 0.5 ? color.lightness : 1 - color.lightness); + if (v != 0) s = 2 - 2 * color.lightness / v; + + return HSVColor.fromAHSV( + color.alpha, + color.hue, + s.clamp(0.0, 1.0), + v.clamp(0.0, 1.0), + ); +} + +/// [RegExp] pattern for validation HEX color [String] inputs, allows only: +/// +/// * exactly 1 to 8 digits in HEX format, +/// * only Latin A-F characters, case insensitive, +/// * and integer numbers 0,1,2,3,4,5,6,7,8,9, +/// * with optional hash (`#`) symbol at the beginning (not calculated in length). +/// +/// ```dart +/// final RegExp hexInputValidator = RegExp(kValidHexPattern); +/// if (hexInputValidator.hasMatch(hex)) print('$hex might be a valid HEX color'); +/// ``` +/// Reference: https://en.wikipedia.org/wiki/Web_colors#Hex_triplet +const String kValidHexPattern = r'^#?[0-9a-fA-F]{1,8}'; + +/// [RegExp] pattern for validation complete HEX color [String], allows only: +/// +/// * exactly 6 or 8 digits in HEX format, +/// * only Latin A-F characters, case insensitive, +/// * and integer numbers 0,1,2,3,4,5,6,7,8,9, +/// * with optional hash (`#`) symbol at the beginning (not calculated in length). +/// +/// ```dart +/// final RegExp hexCompleteValidator = RegExp(kCompleteValidHexPattern); +/// if (hexCompleteValidator.hasMatch(hex)) print('$hex is valid HEX color'); +/// ``` +/// Reference: https://en.wikipedia.org/wiki/Web_colors#Hex_triplet +const String kCompleteValidHexPattern = r'^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$'; + +/// Try to convert text input or any [String] to valid [Color]. +/// The [String] must be provided in one of those formats: +/// +/// * RGB +/// * #RGB +/// * RRGGBB +/// * #RRGGBB +/// * AARRGGBB +/// * #AARRGGBB +/// +/// Where: A stands for Alpha, R for Red, G for Green, and B for blue color. +/// It will only accept 3/6/8 long HEXs with an optional hash (`#`) at the beginning. +/// Allowed characters are Latin A-F case insensitive and numbers 0-9. +/// Optional [enableAlpha] can be provided (it's `true` by default). If it's set +/// to `false` transparency information (alpha channel) will be removed. +/// ```dart +/// /// // Valid 3 digit HEXs: +/// colorFromHex('abc') == Color(0xffaabbcc) +/// colorFromHex('ABc') == Color(0xffaabbcc) +/// colorFromHex('ABC') == Color(0xffaabbcc) +/// colorFromHex('#Abc') == Color(0xffaabbcc) +/// colorFromHex('#abc') == Color(0xffaabbcc) +/// colorFromHex('#ABC') == Color(0xffaabbcc) +/// // Valid 6 digit HEXs: +/// colorFromHex('aabbcc') == Color(0xffaabbcc) +/// colorFromHex('AABbcc') == Color(0xffaabbcc) +/// colorFromHex('AABBCC') == Color(0xffaabbcc) +/// colorFromHex('#AABbcc') == Color(0xffaabbcc) +/// colorFromHex('#aabbcc') == Color(0xffaabbcc) +/// colorFromHex('#AABBCC') == Color(0xffaabbcc) +/// // Valid 8 digit HEXs: +/// colorFromHex('ffaabbcc') == Color(0xffaabbcc) +/// colorFromHex('ffAABbcc') == Color(0xffaabbcc) +/// colorFromHex('ffAABBCC') == Color(0xffaabbcc) +/// colorFromHex('ffaabbcc', enableAlpha: true) == Color(0xffaabbcc) +/// colorFromHex('FFAAbbcc', enableAlpha: true) == Color(0xffaabbcc) +/// colorFromHex('ffAABBCC', enableAlpha: true) == Color(0xffaabbcc) +/// colorFromHex('FFaabbcc', enableAlpha: true) == Color(0xffaabbcc) +/// colorFromHex('#ffaabbcc') == Color(0xffaabbcc) +/// colorFromHex('#ffAABbcc') == Color(0xffaabbcc) +/// colorFromHex('#FFAABBCC') == Color(0xffaabbcc) +/// colorFromHex('#ffaabbcc', enableAlpha: true) == Color(0xffaabbcc) +/// colorFromHex('#FFAAbbcc', enableAlpha: true) == Color(0xffaabbcc) +/// colorFromHex('#ffAABBCC', enableAlpha: true) == Color(0xffaabbcc) +/// colorFromHex('#FFaabbcc', enableAlpha: true) == Color(0xffaabbcc) +/// // Invalid HEXs: +/// colorFromHex('bc') == null // length 2 +/// colorFromHex('aabbc') == null // length 5 +/// colorFromHex('#ffaabbccd') == null // length 9 (+#) +/// colorFromHex('aabbcx') == null // x character +/// colorFromHex('#aabbвв') == null // в non-latin character +/// colorFromHex('') == null // empty +/// ``` +/// Reference: https://en.wikipedia.org/wiki/Web_colors#Hex_triplet +Color? colorFromHex(String inputString, {bool enableAlpha = true}) { + // Registers validator for exactly 6 or 8 digits long HEX (with optional #). + final RegExp hexValidator = RegExp(kCompleteValidHexPattern); + // Validating input, if it does not match — it's not proper HEX. + if (!hexValidator.hasMatch(inputString)) return null; + // Remove optional hash if exists and convert HEX to UPPER CASE. + String hexToParse = inputString.replaceFirst('#', '').toUpperCase(); + // It may allow HEXs with transparency information even if alpha is disabled, + if (!enableAlpha && hexToParse.length == 8) { + // but it will replace this info with 100% non-transparent value (FF). + hexToParse = 'FF${hexToParse.substring(2)}'; + } + // HEX may be provided in 3-digits format, let's just duplicate each letter. + if (hexToParse.length == 3) { + hexToParse = hexToParse.split('').expand((i) => [i * 2]).join(); + } + // We will need 8 digits to parse the color, let's add missing digits. + if (hexToParse.length == 6) hexToParse = 'FF$hexToParse'; + // HEX must be valid now, but as a precaution, it will just "try" to parse it. + final intColorValue = int.tryParse(hexToParse, radix: 16); + // If for some reason HEX is not valid — abort the operation, return nothing. + if (intColorValue == null) return null; + // Register output color for the last step. + final color = Color(intColorValue); + // Decide to return color with transparency information or not. + return enableAlpha ? color : color.withAlpha(255); +} + +/// Converts `dart:ui` [Color] to the 6/8 digits HEX [String]. +/// +/// Prefixes a hash (`#`) sign if [includeHashSign] is set to `true`. +/// The result will be provided as UPPER CASE, it can be changed via [toUpperCase] +/// flag set to `false` (default is `true`). Hex can be returned without alpha +/// channel information (transparency), with the [enableAlpha] flag set to `false`. +String colorToHex( + Color color, { + bool includeHashSign = false, + bool enableAlpha = true, + bool toUpperCase = true, +}) { + final String hex = (includeHashSign ? '#' : '') + + (enableAlpha ? _padRadix(color.alpha) : '') + + _padRadix(color.red) + + _padRadix(color.green) + + _padRadix(color.blue); + return toUpperCase ? hex.toUpperCase() : hex; +} + +// Shorthand for padLeft of RadixString, DRY. +String _padRadix(int value) => value.toRadixString(16).padLeft(2, '0'); + +// Extension for String +extension ColorExtension1 on String { + Color? toColor() { + Color? color = colorFromName(this); + if (color != null) return color; + return colorFromHex(this); + } +} + +// Extension from Color +extension ColorExtension2 on Color { + String toHexString({bool includeHashSign = false, bool enableAlpha = true, bool toUpperCase = true}) => + colorToHex(this, includeHashSign: false, enableAlpha: true, toUpperCase: true); +} diff --git a/filcnaplo_premium/lib/ui/mobile/goal_planner/goal_input.dart b/filcnaplo_premium/lib/ui/mobile/goal_planner/goal_input.dart new file mode 100644 index 0000000..1b26475 --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/goal_planner/goal_input.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class GoalInput extends StatelessWidget { + const GoalInput({Key? key, required this.currentAverage, required this.value, required this.onChanged}) : super(key: key); + + final double currentAverage; + final double value; + final void Function(double value) onChanged; + + void offsetToValue(Offset offset, Size size) { + double v = ((offset.dx / size.width * 4 + 1) * 10).round() / 10; + v = v.clamp(1.5, 5); + v = v.clamp(((currentAverage * 10).round() / 10), 5); + setValue(v); + } + + void setValue(double v) { + if (v != value) { + HapticFeedback.lightImpact(); + } + onChanged(v); + } + + @override + Widget build(BuildContext context) { + List presets = [2, 3, 4, 5]; + presets = presets.where((e) => gradeToAvg(e) > currentAverage).toList(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + LayoutBuilder(builder: (context, size) { + return GestureDetector( + onTapDown: (details) { + offsetToValue(details.localPosition, size.biggest); + }, + onHorizontalDragUpdate: (details) { + offsetToValue(details.localPosition, size.biggest); + }, + child: SizedBox( + height: 32.0, + width: double.infinity, + child: Padding( + padding: const EdgeInsets.only(right: 20.0), + child: CustomPaint( + painter: GoalSliderPainter(value: (value - 1) / 4), + ), + ), + ), + ); + }), + const SizedBox(height: 12.0), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: presets.map((e) { + final pv = (value * 10).round() / 10; + final selected = gradeToAvg(e) == pv; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(99.0), + color: gradeColor(e).withOpacity(selected ? 1.0 : 0.2), + border: Border.all(color: gradeColor(e), width: 4), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(99.0), + onTap: () => setValue(gradeToAvg(e)), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 24.0), + child: Text( + e.toString(), + style: TextStyle( + color: selected ? Colors.white : gradeColor(e), + fontWeight: FontWeight.bold, + fontSize: 24.0, + ), + ), + ), + ), + ), + ), + ); + }).toList(), + ) + ], + ); + } +} + +class GoalSliderPainter extends CustomPainter { + final double value; + + GoalSliderPainter({required this.value}); + + @override + void paint(Canvas canvas, Size size) { + final radius = size.height / 2; + const cpadding = 4; + final rect = Rect.fromLTWH(0, 0, size.width + radius, size.height); + final vrect = Rect.fromLTWH(0, 0, size.width * value + radius, size.height); + canvas.drawRRect( + RRect.fromRectAndRadius( + rect, + const Radius.circular(99.0), + ), + Paint()..color = Colors.black.withOpacity(.1), + ); + canvas.drawRRect( + RRect.fromRectAndRadius( + vrect, + const Radius.circular(99.0), + ), + Paint() + ..shader = const LinearGradient(colors: [ + Color(0xffFF3B30), + Color(0xffFF9F0A), + Color(0xffFFD60A), + Color(0xff34C759), + Color(0xff247665), + ]).createShader(rect), + ); + canvas.drawOval( + Rect.fromCircle(center: Offset(size.width * value, size.height / 2), radius: radius - cpadding), + Paint()..color = Colors.white, + ); + for (int i = 1; i < 4; i++) { + canvas.drawOval( + Rect.fromCircle(center: Offset(size.width / 4 * i, size.height / 2), radius: 4), + Paint()..color = Colors.white.withOpacity(.5), + ); + } + } + + @override + bool shouldRepaint(GoalSliderPainter oldDelegate) { + return oldDelegate.value != value; + } +} + +double gradeToAvg(int grade) { + return grade - 0.5; +} + +Color gradeColor(int grade) { + return [ + const Color(0xffFF3B30), + const Color(0xffFF9F0A), + const Color(0xffFFD60A), + const Color(0xff34C759), + const Color(0xff247665), + ].elementAt(grade.clamp(1, 5) - 1); +} diff --git a/filcnaplo_premium/lib/ui/mobile/goal_planner/goal_planner.dart b/filcnaplo_premium/lib/ui/mobile/goal_planner/goal_planner.dart new file mode 100644 index 0000000..cbacb55 --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/goal_planner/goal_planner.dart @@ -0,0 +1,172 @@ +/* + * Maintainer: DarK + * Translated from C version + * Minimal Working Fixed @ 2022.12.25 + * ##Please do NOT modify if you don't know whats going on## + * + * Issue: #59 + * + * Future changes / ideas: + * - `best` should be configurable + */ +import 'dart:math'; +import 'package:filcnaplo_kreta_api/models/category.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_kreta_api/models/subject.dart'; +import 'package:flutter/foundation.dart' show listEquals; + +/// Generate list of grades that achieve the wanted goal. +/// After generating possible options, it (when doing so would NOT result in empty list) filters with two criteria: +/// - Plan should not contain more than 15 grades +/// - Plan should not contain only one type of grade +/// +/// **Usage**: +/// +/// ```dart +/// List GoalPlanner(double goal, List grades).solve().plan +/// ``` +class GoalPlanner { + final double goal; + final List grades; + List plans = []; + GoalPlanner(this.goal, this.grades); + + bool _allowed(int grade) => grade > goal; + + void _generate(Generator g) { + // Exit condition 1: Generator has working plan. + if (g.currentAvg.avg >= goal) { + plans.add(Plan(g.plan)); + return; + } + // Exit condition 2: Generator plan will never work. + if (!_allowed(g.gradeToAdd)) { + return; + } + + for (int i = g.max; i >= 0; i--) { + int newGradeToAdd = g.gradeToAdd - 1; + List newPlan = GoalPlannerHelper._addToList(g.plan, g.gradeToAdd, i); + + Avg newAvg = GoalPlannerHelper._addToAvg(g.currentAvg, g.gradeToAdd, i); + int newN = GoalPlannerHelper.howManyNeeded( + newGradeToAdd, + grades + + newPlan + .map((e) => Grade( + id: '', + date: DateTime(0), + value: GradeValue(e, '', '', 100), + teacher: '', + description: '', + form: '', + groupId: '', + type: GradeType.midYear, + subject: Subject.fromJson({}), + mode: Category.fromJson({}), + seenDate: DateTime(0), + writeDate: DateTime(0), + )) + .toList(), + goal); + + _generate(Generator(newGradeToAdd, newN, newAvg, newPlan)); + } + } + + List solve() { + _generate( + Generator( + 5, + GoalPlannerHelper.howManyNeeded( + 5, + grades, + goal, + ), + Avg(GoalPlannerHelper.averageEvals(grades), GoalPlannerHelper.weightSum(grades)), + [], + ), + ); + + // Calculate Statistics + for (var e in plans) { + e.sum = e.plan.fold(0, (int a, b) => a + b); + e.avg = e.sum / e.plan.length; + e.sigma = sqrt(e.plan.map((i) => pow(i - e.avg, 2)).fold(0, (num a, b) => a + b) / e.plan.length); + } + + // filter without aggression + if (plans.where((e) => e.plan.length < 30).isNotEmpty) { + plans.removeWhere((e) => !(e.plan.length < 30)); + } + if (plans.where((e) => e.sigma > 1).isNotEmpty) { + plans.removeWhere((e) => !(e.sigma > 1)); + } + + return plans; + } +} + +class Avg { + final double avg; + final double n; + + Avg(this.avg, this.n); +} + +class Generator { + final int gradeToAdd; + final int max; + final Avg currentAvg; + final List plan; + + Generator(this.gradeToAdd, this.max, this.currentAvg, this.plan); +} + +class Plan { + final List plan; + int sum = 0; + double avg = 0; + int med = 0; // currently + int mod = 0; // unused + double sigma = 0; + + Plan(this.plan); + + @override + bool operator ==(other) => other is Plan && listEquals(plan, other.plan); + + @override + int get hashCode => Object.hashAll(plan); +} + +class GoalPlannerHelper { + static Avg _addToAvg(Avg base, int grade, int n) => Avg((base.avg * base.n + grade * n) / (base.n + n), base.n + n); + + static List _addToList(List l, T e, int n) { + if (n == 0) return l; + List tmp = l; + for (int i = 0; i < n; i++) { + tmp = tmp + [e]; + } + return tmp; + } + + static int howManyNeeded(int grade, List base, double goal) { + double avg = averageEvals(base); + double wsum = weightSum(base); + if (avg >= goal) return 0; + if (grade * 1.0 == goal) return -1; + int candidate = (wsum * (avg - goal) / (goal - grade)).floor(); + return (candidate * grade + avg * wsum) / (candidate + wsum) < goal ? candidate + 1 : candidate; + } + + static double averageEvals(List grades, {bool finalAvg = false}) { + double average = + grades.map((e) => e.value.value * e.value.weight / 100.0).fold(0.0, (double a, double b) => a + b) / weightSum(grades, finalAvg: finalAvg); + return average.isNaN ? 0.0 : average; + } + + static double weightSum(List grades, {bool finalAvg = false}) => + grades.map((e) => finalAvg ? 1 : e.value.weight / 100).fold(0, (a, b) => a + b); +} diff --git a/filcnaplo_premium/lib/ui/mobile/goal_planner/grade_display.dart b/filcnaplo_premium/lib/ui/mobile/goal_planner/grade_display.dart new file mode 100644 index 0000000..631973f --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/goal_planner/grade_display.dart @@ -0,0 +1,30 @@ +import 'package:filcnaplo_premium/ui/mobile/goal_planner/goal_input.dart'; +import 'package:flutter/material.dart'; + +class GradeDisplay extends StatelessWidget { + const GradeDisplay({Key? key, required this.grade}) : super(key: key); + + final int grade; + + @override + Widget build(BuildContext context) { + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: gradeColor(grade).withOpacity(.3), + ), + child: Center( + child: Text( + grade.toInt().toString(), + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 22.0, + color: gradeColor(grade), + ), + ), + ), + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/goal_planner/route_option.dart b/filcnaplo_premium/lib/ui/mobile/goal_planner/route_option.dart new file mode 100644 index 0000000..cfcc55a --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/goal_planner/route_option.dart @@ -0,0 +1,126 @@ +import 'package:filcnaplo_premium/ui/mobile/goal_planner/goal_planner.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/grade_display.dart'; +import 'package:flutter/material.dart'; + +enum RouteMark { recommended, fastest } + +class RouteOption extends StatelessWidget { + const RouteOption({Key? key, required this.plan, this.mark, this.selected = false, required this.onSelected}) : super(key: key); + + final Plan plan; + final RouteMark? mark; + final bool selected; + final void Function() onSelected; + + Widget markLabel() { + const style = TextStyle(fontWeight: FontWeight.bold); + + switch (mark!) { + case RouteMark.recommended: + return const Text("Recommended", style: style); + case RouteMark.fastest: + return const Text("Fastest", style: style); + } + } + + Color markColor() { + switch (mark) { + case RouteMark.recommended: + return const Color(0xff6a63d4); + case RouteMark.fastest: + return const Color(0xffe9d524); + default: + return Colors.teal; + } + } + + @override + Widget build(BuildContext context) { + List gradeWidgets = []; + + for (int i = 5; i > 1; i--) { + final count = plan.plan.where((e) => e == i).length; + + if (count > 4) { + gradeWidgets.add(Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "${count}x", + style: TextStyle( + fontSize: 22.0, + fontWeight: FontWeight.w500, + color: Colors.black.withOpacity(.7), + ), + ), + const SizedBox(width: 4.0), + GradeDisplay(grade: i), + ], + )); + } else { + gradeWidgets.addAll(List.generate(count, (_) => GradeDisplay(grade: i))); + } + + if (count > 0) { + gradeWidgets.add(SizedBox( + height: 36.0, + width: 32.0, + child: Center(child: Icon(Icons.add, color: Colors.black.withOpacity(.5))), + )); + } + } + + gradeWidgets.removeLast(); + + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: SizedBox( + width: double.infinity, + child: Card( + surfaceTintColor: selected ? markColor().withOpacity(.2) : Colors.white, + margin: EdgeInsets.zero, + elevation: 5, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16.0), + side: selected ? BorderSide(color: markColor(), width: 4.0) : BorderSide.none, + ), + child: InkWell( + borderRadius: BorderRadius.circular(16.0), + onTap: onSelected, + child: Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 16.0, left: 20.0, right: 12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (mark != null) ...[ + Chip( + label: markLabel(), + visualDensity: VisualDensity.compact, + backgroundColor: selected ? markColor() : Colors.transparent, + labelPadding: const EdgeInsets.symmetric(horizontal: 8.0), + labelStyle: TextStyle(color: selected ? Colors.white : null), + shape: StadiumBorder( + side: BorderSide( + color: markColor(), + width: 3.0, + ), + ), + ), + const SizedBox(height: 6.0), + ], + Wrap( + spacing: 4.0, + runSpacing: 8.0, + children: gradeWidgets, + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/goal_planner/test.dart b/filcnaplo_premium/lib/ui/mobile/goal_planner/test.dart new file mode 100644 index 0000000..78126bb --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/goal_planner/test.dart @@ -0,0 +1,209 @@ +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/goal_input.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/goal_planner.dart'; +import 'package:filcnaplo_premium/ui/mobile/goal_planner/route_option.dart'; +import 'package:flutter/material.dart'; + +enum PlanResult { + available, // There are possible solutions + unreachable, // The solutions are too hard don't even try + unsolvable, // There are no solutions + reached, // Goal already reached +} + +class GoalPlannerTest extends StatefulWidget { + const GoalPlannerTest({Key? key}) : super(key: key); + + @override + State createState() => _GoalPlannerTestState(); +} + +class _GoalPlannerTestState extends State { + double goalValue = 4.0; + List grades = []; + + Plan? recommended; + Plan? fastest; + Plan? selectedRoute; + List otherPlans = []; + + PlanResult getResult() { + final currentAvg = GoalPlannerHelper.averageEvals(grades); + + recommended = null; + fastest = null; + otherPlans = []; + + if (currentAvg >= goalValue) return PlanResult.reached; + + final planner = GoalPlanner(goalValue, grades); + final plans = planner.solve(); + + plans.sort((a, b) => (a.avg - (2 * goalValue + 5) / 3).abs().compareTo(b.avg - (2 * goalValue + 5) / 3)); + + try { + final singleSolution = plans.every((e) => e.sigma == 0); + recommended = plans.where((e) => singleSolution ? true : e.sigma > 0).first; + plans.removeWhere((e) => e == recommended); + } catch (_) {} + + plans.sort((a, b) => a.plan.length.compareTo(b.plan.length)); + + try { + fastest = plans.removeAt(0); + } catch (_) {} + + if ((recommended?.plan.length ?? 0) - (fastest?.plan.length ?? 0) >= 3) { + recommended = fastest; + } + + if (recommended == null) { + recommended = null; + fastest = null; + otherPlans = []; + selectedRoute = null; + return PlanResult.unsolvable; + } + + if (recommended!.plan.length > 10) { + recommended = null; + fastest = null; + otherPlans = []; + selectedRoute = null; + return PlanResult.unreachable; + } + + otherPlans = List.from(plans); + + return PlanResult.available; + } + + @override + Widget build(BuildContext context) { + final currentAvg = GoalPlannerHelper.averageEvals(grades); + + final result = getResult(); + + return Scaffold( + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(20.0), + children: [ + const Align( + alignment: Alignment.topLeft, + child: BackButton(), + ), + const SizedBox(height: 12.0), + const Text( + "Set a goal", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20.0, + ), + ), + const SizedBox(height: 4.0), + Text( + goalValue.toString(), + style: TextStyle( + fontWeight: FontWeight.w900, + fontSize: 42.0, + color: gradeColor(goalValue.round()), + ), + ), + const SizedBox(height: 24.0), + const Text( + "Pick a route", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20.0, + ), + ), + const SizedBox(height: 12.0), + if (recommended != null) + RouteOption( + plan: recommended!, + mark: RouteMark.recommended, + selected: selectedRoute == recommended!, + onSelected: () => setState(() { + selectedRoute = recommended; + }), + ), + if (fastest != null && fastest != recommended) + RouteOption( + plan: fastest!, + mark: RouteMark.fastest, + selected: selectedRoute == fastest!, + onSelected: () => setState(() { + selectedRoute = fastest; + }), + ), + ...otherPlans.map((e) => RouteOption( + plan: e, + selected: selectedRoute == e, + onSelected: () => setState(() { + selectedRoute = e; + }), + )), + if (result != PlanResult.available) Text(result.name), + ], + ), + ), + bottomSheet: MediaQuery.removePadding( + context: context, + removeBottom: false, + removeTop: true, + child: Container( + color: Theme.of(context).scaffoldBackgroundColor, + child: Container( + padding: const EdgeInsets.only(top: 24.0), + decoration: BoxDecoration( + color: const Color.fromARGB(255, 215, 255, 242), + borderRadius: const BorderRadius.vertical(top: Radius.circular(24.0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(.1), + blurRadius: 8.0, + ) + ]), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + GoalInput( + value: goalValue, + currentAverage: currentAvg, + onChanged: (v) => setState(() { + selectedRoute = null; + goalValue = v; + }), + ), + const SizedBox(height: 24.0), + SizedBox( + width: double.infinity, + child: RawMaterialButton( + onPressed: () {}, + fillColor: const Color(0xff01342D), + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: const Text( + "Track it!", + style: TextStyle( + color: Colors.white, + fontSize: 20.0, + fontWeight: FontWeight.w600, + ), + ), + ), + ) + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/grades/average_selector.dart b/filcnaplo_premium/lib/ui/mobile/grades/average_selector.dart new file mode 100644 index 0000000..8013e5f --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/grades/average_selector.dart @@ -0,0 +1,92 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_premium/models/premium_scopes.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/upsell.dart'; +import 'package:flutter/material.dart'; +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:filcnaplo_mobile_ui/pages/grades/grades_page.i18n.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:provider/provider.dart'; + +final Map avgDropItems = { + 0: "annual_average".i18n, + 90: "3_months_average".i18n, + 30: "30_days_average".i18n, + 14: "14_days_average".i18n, + 7: "7_days_average".i18n, +}; + +class PremiumAverageSelector extends StatelessWidget { + const PremiumAverageSelector({Key? key, this.onChanged, required this.value}) : super(key: key); + + final Function(int?)? onChanged; + final int value; + + @override + Widget build(BuildContext context) { + return DropdownButton2( + items: avgDropItems.keys + .map((item) => DropdownMenuItem( + value: item, + child: Text( + avgDropItems[item] ?? "", + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.of(context).text, + ), + overflow: TextOverflow.ellipsis, + ), + )) + .toList(), + onChanged: (int? value) { + if (Provider.of(context, listen: false).hasScope(PremiumScopes.gradeStats)) { + if (onChanged != null) onChanged!(value); + } else { + PremiumLockedFeatureUpsell.show(context: context, feature: PremiumFeature.gradestats); + } + }, + value: value, + iconSize: 14, + iconEnabledColor: AppColors.of(context).text, + iconDisabledColor: AppColors.of(context).text, + underline: const SizedBox(), + itemHeight: 40, + itemPadding: const EdgeInsets.only(left: 14, right: 14), + dropdownWidth: 200, + dropdownPadding: null, + buttonDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + dropdownDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + ), + dropdownElevation: 8, + scrollbarRadius: const Radius.circular(40), + scrollbarThickness: 6, + scrollbarAlwaysShow: true, + offset: const Offset(-10, -10), + buttonSplashColor: Colors.transparent, + customButton: SizedBox( + height: 30, + child: Row( + children: [ + Text(avgDropItems[value] ?? "", + style: Theme.of(context) + .textTheme + .titleSmall! + .copyWith(fontWeight: FontWeight.w600, color: AppColors.of(context).text.withOpacity(0.65))), + const SizedBox( + width: 4, + ), + Icon( + FeatherIcons.chevronDown, + size: 16, + color: AppColors.of(context).text, + ), + ], + ), + ), + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/premium/activation_view/activation_dashboard.dart b/filcnaplo_premium/lib/ui/mobile/premium/activation_view/activation_dashboard.dart new file mode 100644 index 0000000..0a113a2 --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/premium/activation_view/activation_dashboard.dart @@ -0,0 +1,182 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; + +class ActivationDashboard extends StatefulWidget { + const ActivationDashboard({super.key}); + + @override + State createState() => _ActivationDashboardState(); +} + +class _ActivationDashboardState extends State { + bool manualActivationLoading = false; + + Future onManualActivation() async { + final data = await Clipboard.getData("text/plain"); + if (data == null || data.text == null || data.text == "") { + return; + } + setState(() { + manualActivationLoading = true; + }); + final result = await context.read().auth.finishAuth(data.text!); + setState(() { + manualActivationLoading = false; + }); + + if (!result && mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text( + "Sikertelen aktiválás. Kérlek próbáld újra később!", + style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), + ), + backgroundColor: Colors.red, + )); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(), + Center( + child: SvgPicture.asset( + "assets/images/github.svg", + height: 64.0, + ), + ), + const SizedBox(height: 32.0), + const Text( + "Jelentkezz be a GitHub felületén és adj hozzáférést a Filcnek, hogy aktiváld a Premiumot.", + textAlign: TextAlign.center, + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 18.0), + ), + const SizedBox(height: 12.0), + Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14.0)), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: const [ + Icon(FeatherIcons.alertTriangle, size: 20.0, color: Colors.orange), + SizedBox(width: 12.0), + Text( + "Figyelem!", + style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 6.0), + const Text( + "Csak akkor érzékeli a Filc a támogatói státuszod, ha nem állítod privátra!", + style: TextStyle(fontSize: 16.0), + ), + ], + ), + ), + ), + const SizedBox(height: 12.0), + Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14.0)), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: const [ + Icon(FeatherIcons.alertTriangle, size: 20.0, color: Colors.orange), + SizedBox(width: 12.0), + Text( + "Figyelem!", + style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 6.0), + const Text( + "Ha friss támogató vagy, 5-10 percbe telhet az aktiválás. Kérlek gyere vissza később, és próbáld újra!", + style: TextStyle(fontSize: 16.0), + ), + ], + ), + ), + ), + const SizedBox(height: 12.0), + Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14.0)), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "Ha bejelentkezés után nem lép vissza az alkalmazásba automatikusan, aktiváld a támogatásod manuálisan", + style: TextStyle(fontSize: 15.0, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 6.0), + Center( + child: TextButton.icon( + onPressed: onManualActivation, + style: ButtonStyle( + foregroundColor: MaterialStatePropertyAll(Theme.of(context).colorScheme.secondary), + overlayColor: MaterialStatePropertyAll(Theme.of(context).colorScheme.secondary.withOpacity(.1)), + ), + icon: manualActivationLoading + ? const SizedBox( + child: CircularProgressIndicator(), + height: 16.0, + width: 16.0, + ) + : const Icon(FeatherIcons.key, size: 20.0), + label: const Padding( + padding: EdgeInsets.only(left: 8.0), + child: Text( + "Aktiválás tokennel", + style: TextStyle(fontSize: 16.0), + ), + ), + ), + ), + ], + ), + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: Center( + child: TextButton.icon( + onPressed: () { + Navigator.of(context).pop(); + }, + style: ButtonStyle( + foregroundColor: MaterialStatePropertyAll(AppColors.of(context).text), + overlayColor: MaterialStatePropertyAll(AppColors.of(context).text.withOpacity(.1)), + ), + icon: const Icon(FeatherIcons.arrowLeft, size: 20.0), + label: const Text( + "Vissza", + style: TextStyle(fontSize: 16.0), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/premium/activation_view/activation_view.dart b/filcnaplo_premium/lib/ui/mobile/premium/activation_view/activation_view.dart new file mode 100644 index 0000000..118e907 --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/premium/activation_view/activation_view.dart @@ -0,0 +1,67 @@ +import 'package:animations/animations.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/activation_view/activation_dashboard.dart'; +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; +import 'package:provider/provider.dart'; + +class PremiumActivationView extends StatefulWidget { + const PremiumActivationView({super.key}); + + @override + State createState() => _PremiumActivationViewState(); +} + +class _PremiumActivationViewState extends State with SingleTickerProviderStateMixin { + late AnimationController animation; + bool activated = false; + + @override + void initState() { + super.initState(); + context.read().auth.initAuth(); + + animation = AnimationController(vsync: this, duration: const Duration(seconds: 2)); + } + + @override + void dispose() { + animation.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final premium = context.watch(); + + if (premium.hasPremium && !activated) { + activated = true; + animation.forward(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + Future.delayed(const Duration(seconds: 2)).then((value) { + if (mounted) Navigator.of(context).pop(); + }); + }); + } + + return Scaffold( + body: PageTransitionSwitcher( + transitionBuilder: (child, primaryAnimation, secondaryAnimation) => SharedAxisTransition( + animation: primaryAnimation, + secondaryAnimation: secondaryAnimation, + transitionType: SharedAxisTransitionType.horizontal, + fillColor: Colors.transparent, + child: child, + ), + child: premium.hasPremium + ? Center( + child: SizedBox( + width: 400, + child: Lottie.network("https://assets2.lottiefiles.com/packages/lf20_wkebwzpz.json", controller: animation), + ), + ) + : const SafeArea(child: ActivationDashboard()), + ), + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/premium/premium_inline.dart b/filcnaplo_premium/lib/ui/mobile/premium/premium_inline.dart new file mode 100644 index 0000000..35c29fb --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/premium/premium_inline.dart @@ -0,0 +1,66 @@ +import 'package:filcnaplo_premium/ui/mobile/premium/upsell.dart'; +import 'package:flutter/material.dart'; + +enum PremiumInlineFeature { nickname, theme, widget, goal, stats } + +const Map _featureAssets = { + PremiumInlineFeature.nickname: "assets/images/premium_nickname_inline_showcase.png", + PremiumInlineFeature.theme: "assets/images/premium_theme_inline_showcase.png", + PremiumInlineFeature.widget: "assets/images/premium_widget_inline_showcase.png", + PremiumInlineFeature.goal: "assets/images/premium_goal_inline_showcase.png", + PremiumInlineFeature.stats: "assets/images/premium_stats_inline_showcase.png", +}; + +const Map _featuresInline = { + PremiumInlineFeature.nickname: PremiumFeature.profile, + PremiumInlineFeature.theme: PremiumFeature.customcolors, + PremiumInlineFeature.widget: PremiumFeature.widget, + PremiumInlineFeature.goal: PremiumFeature.goalplanner, + PremiumInlineFeature.stats: PremiumFeature.gradestats, +}; + +class PremiumInline extends StatelessWidget { + const PremiumInline({super.key, required this.features}); + + final List features; + + String _getAsset() { + for (int i = 0; i < features.length; i++) { + if (DateTime.now().day % features.length == i) { + return _featureAssets[features[i]]!; + } + } + + return _featureAssets[features[0]]!; + } + + PremiumFeature _getFeature() { + for (int i = 0; i < features.length; i++) { + if (DateTime.now().day % features.length == i) { + return _featuresInline[features[i]]!; + } + } + + return _featuresInline[features[0]]!; + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Image.asset(_getAsset()), + Positioned.fill( + child: Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(16.0), + onTap: () { + PremiumLockedFeatureUpsell.show(context: context, feature: _getFeature()); + }, + ), + ), + ), + ], + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/premium/upsell.dart b/filcnaplo_premium/lib/ui/mobile/premium/upsell.dart new file mode 100644 index 0000000..ac82265 --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/premium/upsell.dart @@ -0,0 +1,164 @@ +import 'package:filcnaplo/icons/filc_icons.dart'; +import 'package:filcnaplo_mobile_ui/premium/premium_screen.dart'; +import 'package:flutter/material.dart'; + +enum PremiumFeature { + gradestats, + customcolors, + profile, + iconpack, + subjectrename, + weeklytimetable, + goalplanner, + widget, +} + +enum PremiumFeatureLevel { kupak, tinta } + +const Map _featureLevels = { + PremiumFeature.gradestats: PremiumFeatureLevel.kupak, + PremiumFeature.customcolors: PremiumFeatureLevel.kupak, + PremiumFeature.profile: PremiumFeatureLevel.kupak, + PremiumFeature.iconpack: PremiumFeatureLevel.kupak, + PremiumFeature.subjectrename: PremiumFeatureLevel.kupak, + PremiumFeature.weeklytimetable: PremiumFeatureLevel.tinta, + PremiumFeature.goalplanner: PremiumFeatureLevel.tinta, + PremiumFeature.widget: PremiumFeatureLevel.tinta, +}; + +const Map _featureAssets = { + PremiumFeature.gradestats: "assets/images/premium_stats_showcase.png", + PremiumFeature.customcolors: "assets/images/premium_theme_showcase.png", + PremiumFeature.profile: "assets/images/premium_nickname_showcase.png", + PremiumFeature.weeklytimetable: "assets/images/premium_timetable_showcase.png", + PremiumFeature.goalplanner: "assets/images/premium_goal_showcase.png", + PremiumFeature.widget: "assets/images/premium_widget_showcase.png", +}; + +const Map _featureTitles = { + PremiumFeature.gradestats: "Találtál egy prémium funkciót.", + PremiumFeature.customcolors: "Több személyre szabás kell?", + PremiumFeature.profile: "Nem tetszik a neved?", + PremiumFeature.iconpack: "Jobban tetszettek a régi ikonok?", + PremiumFeature.subjectrename: "Sokáig tart elolvasni, hogy \"Földrajz természettudomány\"?", + PremiumFeature.weeklytimetable: "Szeretnéd egyszerre az egész hetet látni?", + PremiumFeature.goalplanner: "Kövesd a céljaidat, sok-sok statisztikával.", + PremiumFeature.widget: "Órák a kezdőképernyőd kényelméből.", +}; + +const Map _featureDescriptions = { + PremiumFeature.gradestats: "Támogass Kupak szinten, hogy több statisztikát láthass. ", + PremiumFeature.customcolors: "Támogass Kupak szinten, és szabd személyre az elemek, a háttér, és a panelek színeit.", + PremiumFeature.profile: "Kupak szinten változtathatod a nevedet, sőt, akár a profilképedet is.", + PremiumFeature.iconpack: "Támogass Kupak szinten, hogy ikon témát választhass.", + PremiumFeature.subjectrename: "Támogass Kupak szinten, hogy átnevezhesd Föcire.", + PremiumFeature.weeklytimetable: "Támogass Tinta szinten a heti órarend funkcióért.", + PremiumFeature.goalplanner: "A célkövetéshez támogass Tinta szinten.", + PremiumFeature.widget: "Támogass Tinta szinten, és helyezz egy widgetet a kezdőképernyődre.", +}; + +class PremiumLockedFeatureUpsell extends StatelessWidget { + const PremiumLockedFeatureUpsell({super.key, required this.feature}); + + static void show({required BuildContext context, required PremiumFeature feature}) => + showDialog(context: context, builder: (context) => PremiumLockedFeatureUpsell(feature: feature)); + + final PremiumFeature feature; + + IconData _getIcon() => _featureLevels[feature] == PremiumFeatureLevel.kupak ? FilcIcons.kupak : FilcIcons.tinta; + Color _getColor(BuildContext context) => _featureLevels[feature] == PremiumFeatureLevel.kupak + ? const Color(0xffC8A708) + : Theme.of(context).brightness == Brightness.light + ? const Color(0xff691A9B) + : const Color(0xffA66FC8); + String? _getAsset() => _featureAssets[feature]; + String _getTitle() => _featureTitles[feature]!; + String _getDescription() => _featureDescriptions[feature]!; + + @override + Widget build(BuildContext context) { + final Color color = _getColor(context); + + return Dialog( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title Bar + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon(_getIcon()), + ), + IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.close), + ), + ], + ), + + // Image showcase + if (_getAsset() != null) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Image.asset(_getAsset()!), + ), + + // Dialog title + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: Text( + _getTitle(), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20.0, + ), + ), + ), + + // Dialog description + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + _getDescription(), + style: const TextStyle( + fontSize: 16.0, + ), + ), + ), + + // CTA button + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: SizedBox( + width: double.infinity, + child: TextButton( + style: ButtonStyle( + backgroundColor: MaterialStatePropertyAll(color.withOpacity(.25)), + foregroundColor: MaterialStatePropertyAll(color), + overlayColor: MaterialStatePropertyAll(color.withOpacity(.1))), + onPressed: () { + Navigator.of(context, rootNavigator: true).push(MaterialPageRoute(builder: (context) { + return const PremiumScreen(); + })); + }, + child: const Text( + "Vigyél oda!", + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18.0, + ), + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/settings/icon_pack.dart b/filcnaplo_premium/lib/ui/mobile/settings/icon_pack.dart new file mode 100644 index 0000000..8cd16bb --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/settings/icon_pack.dart @@ -0,0 +1,34 @@ +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel_button.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/settings_helper.dart'; +import 'package:filcnaplo_premium/models/premium_scopes.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/upsell.dart'; +import 'package:flutter/material.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/settings_screen.i18n.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:provider/provider.dart'; +import 'package:filcnaplo/utils/format.dart'; + +class PremiumIconPackSelector extends StatelessWidget { + const PremiumIconPackSelector({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final settings = Provider.of(context); + + return PanelButton( + onPressed: () { + if (!Provider.of(context, listen: false).hasScope(PremiumScopes.customIcons)) { + PremiumLockedFeatureUpsell.show(context: context, feature: PremiumFeature.iconpack); + return; + } + + SettingsHelper.iconPack(context); + }, + title: Text("icon_pack".i18n), + leading: const Icon(FeatherIcons.grid), + trailing: Text(settings.iconPack.name.capital()), + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/settings/modify_subject_names.dart b/filcnaplo_premium/lib/ui/mobile/settings/modify_subject_names.dart new file mode 100644 index 0000000..dbad6fc --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/settings/modify_subject_names.dart @@ -0,0 +1,383 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:filcnaplo/api/providers/database_provider.dart'; +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'package:filcnaplo_kreta_api/models/subject.dart'; +import 'package:filcnaplo_kreta_api/providers/absence_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/grade_provider.dart'; +import 'package:filcnaplo_kreta_api/providers/timetable_provider.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel_button.dart'; +import 'package:filcnaplo_premium/models/premium_scopes.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/upsell.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:provider/provider.dart'; + +import 'modify_subject_names.i18n.dart'; + +class MenuRenamedSubjects extends StatelessWidget { + const MenuRenamedSubjects({Key? key, required this.settings}) : super(key: key); + + final SettingsProvider settings; + + @override + Widget build(BuildContext context) { + return PanelButton( + padding: const EdgeInsets.only(left: 14.0), + onPressed: () { + if (!Provider.of(context, listen: false).hasScope(PremiumScopes.renameSubjects)) { + PremiumLockedFeatureUpsell.show(context: context, feature: PremiumFeature.subjectrename); + return; + } + + Navigator.of(context, rootNavigator: true).push( + CupertinoPageRoute(builder: (context) => const ModifySubjectNames()), + ); + }, + title: Text( + "rename_subjects".i18n, + style: TextStyle(color: AppColors.of(context).text.withOpacity(settings.renamedSubjectsEnabled ? 1.0 : .5)), + ), + leading: settings.renamedSubjectsEnabled + ? const Icon(FeatherIcons.penTool) + : Icon(FeatherIcons.penTool, color: AppColors.of(context).text.withOpacity(.25)), + trailingDivider: true, + trailing: Switch( + onChanged: (v) async { + if (!Provider.of(context, listen: false).hasScope(PremiumScopes.renameSubjects)) { + PremiumLockedFeatureUpsell.show(context: context, feature: PremiumFeature.subjectrename); + return; + } + + settings.update(renamedSubjectsEnabled: v); + await Provider.of(context, listen: false).convertBySettings(); + await Provider.of(context, listen: false).convertBySettings(); + await Provider.of(context, listen: false).convertBySettings(); + }, + value: settings.renamedSubjectsEnabled, + activeColor: Theme.of(context).colorScheme.secondary, + ), + ); + } +} + +class ModifySubjectNames extends StatefulWidget { + const ModifySubjectNames({Key? key}) : super(key: key); + + @override + State createState() => _ModifySubjectNamesState(); +} + +class _ModifySubjectNamesState extends State { + final GlobalKey _scaffoldKey = GlobalKey(); + final _subjectName = TextEditingController(); + String? selectedSubjectId; + + late List subjects; + late UserProvider user; + late DatabaseProvider dbProvider; + + @override + void initState() { + super.initState(); + subjects = Provider.of(context, listen: false).grades.map((e) => e.subject).toSet().toList() + ..sort((a, b) => a.name.compareTo(b.name)); + user = Provider.of(context, listen: false); + dbProvider = Provider.of(context, listen: false); + } + + Future> fetchRenamedSubjects() async { + return await dbProvider.userQuery.renamedSubjects(userId: user.id!); + } + + void showRenameDialog() { + showDialog( + context: context, + builder: (context) => StatefulBuilder(builder: (context, setS) { + return AlertDialog( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(14.0))), + title: Text("rename_subject".i18n), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButton2( + items: subjects + .map((item) => DropdownMenuItem( + value: item.id, + child: Text( + item.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.of(context).text, + ), + overflow: TextOverflow.ellipsis, + ), + )) + .toList(), + onChanged: (String? v) async { + final renamedSubs = await fetchRenamedSubjects(); + + setS(() { + selectedSubjectId = v; + + if (renamedSubs.containsKey(selectedSubjectId)) { + _subjectName.text = renamedSubs[selectedSubjectId]!; + } else { + _subjectName.text = ""; + } + }); + }, + iconSize: 14, + iconEnabledColor: AppColors.of(context).text, + iconDisabledColor: AppColors.of(context).text, + underline: const SizedBox(), + itemHeight: 40, + itemPadding: const EdgeInsets.only(left: 14, right: 14), + buttonWidth: 50, + dropdownWidth: 300, + dropdownPadding: null, + buttonDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + ), + dropdownDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + ), + dropdownElevation: 8, + scrollbarRadius: const Radius.circular(40), + scrollbarThickness: 6, + scrollbarAlwaysShow: true, + offset: const Offset(-10, -10), + buttonSplashColor: Colors.transparent, + customButton: Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey, width: 2), + borderRadius: BorderRadius.circular(12.0), + ), + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 8.0), + child: Text( + selectedSubjectId == null ? "select_subject".i18n : subjects.firstWhere((element) => element.id == selectedSubjectId).name, + style: Theme.of(context) + .textTheme + .titleSmall! + .copyWith(fontWeight: FontWeight.w700, color: AppColors.of(context).text.withOpacity(0.75)), + overflow: TextOverflow.ellipsis, + maxLines: 2, + textAlign: TextAlign.center, + ), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0), + child: Icon(FeatherIcons.arrowDown, size: 32), + ), + TextField( + controller: _subjectName, + decoration: InputDecoration( + border: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.grey, width: 1.5), + borderRadius: BorderRadius.circular(12.0), + ), + focusedBorder: OutlineInputBorder( + borderSide: const BorderSide(color: Colors.grey, width: 1.5), + borderRadius: BorderRadius.circular(12.0), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 12.0), + hintText: "modified_name".i18n, + suffixIcon: IconButton( + icon: const Icon( + FeatherIcons.x, + color: Colors.grey, + ), + onPressed: () { + setState(() { + _subjectName.text = ""; + }); + }, + ), + ), + ), + ], + ), + actions: [ + TextButton( + child: Text( + "cancel".i18n, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + onPressed: () { + Navigator.of(context).maybePop(); + }, + ), + TextButton( + child: Text( + "done".i18n, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + onPressed: () async { + if (selectedSubjectId != null) { + final renamedSubs = await fetchRenamedSubjects(); + + renamedSubs[selectedSubjectId!] = _subjectName.text; + await dbProvider.userStore.storeRenamedSubjects(renamedSubs, userId: user.id!); + await Provider.of(context, listen: false).convertBySettings(); + await Provider.of(context, listen: false).convertBySettings(); + await Provider.of(context, listen: false).convertBySettings(); + } + Navigator.of(context).pop(true); + setState(() {}); + }, + ), + ], + ); + }), + ).then((val) { + _subjectName.text = ""; + selectedSubjectId = null; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + key: _scaffoldKey, + appBar: AppBar( + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + leading: BackButton(color: AppColors.of(context).text), + title: Text( + "modify_subjects".i18n, + style: TextStyle(color: AppColors.of(context).text), + ), + ), + body: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 24.0), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: showRenameDialog, + borderRadius: BorderRadius.circular(12.0), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border.all(color: Colors.grey, width: 2), + borderRadius: BorderRadius.circular(12.0), + ), + padding: const EdgeInsets.symmetric(vertical: 18.0, horizontal: 12.0), + child: Center( + child: Text( + "rename_new_subject".i18n, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 18, + color: AppColors.of(context).text.withOpacity(.85), + ), + ), + ), + ), + ), + const SizedBox( + height: 30, + ), + FutureBuilder>( + future: fetchRenamedSubjects(), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) return Container(); + + return Panel( + title: Text("renamed_subjects".i18n), + child: Column( + children: snapshot.data!.keys.map( + (key) { + Subject? subject = subjects.firstWhere((element) => key == element.id); + String renameTo = snapshot.data![key]!; + return RenamedSubjectItem( + subject: subject, + renamedTo: renameTo, + modifyCallback: () { + setState(() { + selectedSubjectId = subject.id; + _subjectName.text = renameTo; + }); + showRenameDialog(); + }, + removeCallback: () { + setState(() { + Map subs = Map.from(snapshot.data!); + subs.remove(key); + dbProvider.userStore.storeRenamedSubjects(subs, userId: user.id!); + }); + }, + ); + }, + ).toList(), + ), + ); + }, + ), + ], + ), + ), + )); + } +} + +class RenamedSubjectItem extends StatelessWidget { + const RenamedSubjectItem({ + Key? key, + required this.subject, + required this.renamedTo, + required this.modifyCallback, + required this.removeCallback, + }) : super(key: key); + + final Subject subject; + final String renamedTo; + final void Function() modifyCallback; + final void Function() removeCallback; + + @override + Widget build(BuildContext context) { + return ListTile( + minLeadingWidth: 32.0, + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)), + visualDensity: VisualDensity.compact, + onTap: () {}, + leading: Icon(SubjectIcon.resolveVariant(subject: subject, context: context), color: AppColors.of(context).text.withOpacity(.75)), + title: InkWell( + onTap: modifyCallback, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + subject.name.capital(), + style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14, color: AppColors.of(context).text.withOpacity(.75)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + renamedTo, + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 16), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + trailing: InkWell( + onTap: removeCallback, + child: Icon(FeatherIcons.trash, color: AppColors.of(context).red.withOpacity(.75)), + ), + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/settings/modify_subject_names.i18n.dart b/filcnaplo_premium/lib/ui/mobile/settings/modify_subject_names.i18n.dart new file mode 100644 index 0000000..7bfff73 --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/settings/modify_subject_names.i18n.dart @@ -0,0 +1,45 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension SettingsLocalization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "renamed_subjects": "Renamed Subjects", + "rename_subjects": "Rename Subjects", + "rename_subject": "Rename Subject", + "select_subject": "Select Subject", + "modified_name": "Modified Name", + "modify_subjects": "Modify Subjects", + "cancel": "Cancel", + "done": "Done", + "rename_new_subject": "Rename New Subject", + }, + "hu_hu": { + "renamed_subjects": "Átnevezett Tantárgyaid", + "rename_subjects": "Tantárgyak átnevezése", + "rename_subject": "Tantárgy átnevezése", + "select_subject": "Válassz tantárgyat", + "modified_name": "Módosított név", + "modify_subjects": "Tantárgyak átnevezése", + "cancel": "Mégse", + "done": "Kész", + "rename_new_subject": "Új Tantárgy átnevezése", + }, + "de_de": { + "renamed_subjects": "Umbenannte Fächer", + "rename_subjects": "Fächer umbenennen", + "rename_subject": "Fach umbenennen", + "select_subject": "Fach auswählen", + "modified_name": "Geänderter Name", + "modify_subjects": "Fächer ändern", + "cancel": "Abbrechen", + "done": "Erledigt", + "rename_new_subject": "Neues Fach umbenennen", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_premium/lib/ui/mobile/settings/nickname.dart b/filcnaplo_premium/lib/ui/mobile/settings/nickname.dart new file mode 100644 index 0000000..6c057dc --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/settings/nickname.dart @@ -0,0 +1,93 @@ +import 'package:filcnaplo/api/providers/database_provider.dart'; +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_sheet_menu/bottom_sheet_menu_item.dart'; +import 'package:filcnaplo_premium/models/premium_scopes.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/upsell.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/settings_screen.i18n.dart'; +import 'package:provider/provider.dart'; + +class UserMenuNickname extends StatelessWidget { + const UserMenuNickname({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BottomSheetMenuItem( + onPressed: () { + if (!Provider.of(context, listen: false).hasScope(PremiumScopes.nickname)) { + PremiumLockedFeatureUpsell.show(context: context, feature: PremiumFeature.profile); + return; + } + showDialog(context: context, builder: (context) => const UserNicknameEditor()); + }, + icon: const Icon(FeatherIcons.edit2), + title: Text("edit_nickname".i18n), + ); + } +} + +class UserNicknameEditor extends StatefulWidget { + const UserNicknameEditor({Key? key}) : super(key: key); + + @override + State createState() => _UserNicknameEditorState(); +} + +class _UserNicknameEditorState extends State { + final _userName = TextEditingController(); + late final UserProvider user; + + @override + void initState() { + super.initState(); + user = Provider.of(context, listen: false); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text("change_username".i18n), + content: TextField( + controller: _userName, + autofocus: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + label: Text(user.name!), + suffixIcon: IconButton( + icon: const Icon(FeatherIcons.x), + onPressed: () { + setState(() { + _userName.text = ""; + }); + }, + ), + ), + ), + actions: [ + TextButton( + child: Text( + "cancel".i18n, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + onPressed: () { + Navigator.of(context).maybePop(); + }, + ), + TextButton( + child: Text( + "done".i18n, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + onPressed: () { + user.user!.nickname = _userName.text.trim(); + Provider.of(context, listen: false).store.storeUser(user.user!); + Provider.of(context, listen: false).refresh(); + Navigator.of(context).pop(true); + }, + ), + ], + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/settings/profile_pic.dart b/filcnaplo_premium/lib/ui/mobile/settings/profile_pic.dart new file mode 100644 index 0000000..e857df8 --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/settings/profile_pic.dart @@ -0,0 +1,208 @@ +import 'dart:convert'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:filcnaplo/api/providers/database_provider.dart'; +import 'package:filcnaplo/api/providers/user_provider.dart'; +import 'package:filcnaplo_mobile_ui/common/bottom_sheet_menu/bottom_sheet_menu_item.dart'; +import 'package:filcnaplo_premium/models/premium_scopes.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:filcnaplo_mobile_ui/screens/settings/settings_screen.i18n.dart'; +import 'package:provider/provider.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:image_crop/image_crop.dart'; + +class UserMenuProfilePic extends StatelessWidget { + const UserMenuProfilePic({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + if (!Provider.of(context).hasScope(PremiumScopes.nickname)) { + return const SizedBox(); + } + + return BottomSheetMenuItem( + onPressed: () { + showDialog(context: context, builder: (context) => const UserProfilePicEditor()); + }, + icon: const Icon(FeatherIcons.camera), + title: Text("edit_profile_picture".i18n), + ); + } +} + +class UserProfilePicEditor extends StatefulWidget { + const UserProfilePicEditor({Key? key}) : super(key: key); + + @override + State createState() => _UserProfilePicEditorState(); +} + +class _UserProfilePicEditorState extends State { + late final UserProvider user; + + final cropKey = GlobalKey(); + File? _file; + File? _sample; + File? _lastCropped; + + File? image; + Future pickImage() async { + try { + final image = await ImagePicker().pickImage(source: ImageSource.gallery); + if (image == null) return; + File imageFile = File(image.path); + + final sample = await ImageCrop.sampleImage( + file: imageFile, + preferredSize: context.size!.longestSide.ceil(), + ); + + _sample?.delete(); + _file?.delete(); + + setState(() { + _sample = sample; + _file = imageFile; + }); + } on PlatformException catch (e) { + log('Failed to pick image: $e'); + } + } + + Widget cropImageWidget() { + return SizedBox( + height: 300, + child: Crop.file( + _sample!, + key: cropKey, + aspectRatio: 1.0, + )); + } + + Widget openImageWidget() { + return InkWell( + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14.0), + ), + onTap: () => pickImage(), + child: Container( + decoration: BoxDecoration(border: Border.all(color: Colors.grey), borderRadius: BorderRadius.circular(14.0)), + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 32.0, horizontal: 8.0), + child: Column( + children: [ + Text( + "click_here".i18n, + style: const TextStyle(fontSize: 22.0, fontWeight: FontWeight.w600), + ), + Text( + "select_profile_picture".i18n, + style: const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w500), + ) + ], + ), + ), + ); + } + + Future _cropImage() async { + final scale = cropKey.currentState!.scale; + final area = cropKey.currentState!.area; + if (area == null || _file == null) { + return; + } + + final sample = await ImageCrop.sampleImage( + file: _file!, + preferredSize: (2000 / scale).round(), + ); + + final file = await ImageCrop.cropImage( + file: sample, + area: area, + ); + + sample.delete(); + + _lastCropped?.delete(); + _lastCropped = file; + + List imageBytes = await _lastCropped!.readAsBytes(); + String base64Image = base64Encode(imageBytes); + user.user!.picture = base64Image; + Provider.of(context, listen: false).store.storeUser(user.user!); + Provider.of(context, listen: false).refresh(); + + debugPrint('$file'); + } + + @override + void initState() { + super.initState(); + user = Provider.of(context, listen: false); + } + + @override + void dispose() { + super.dispose(); + _file?.delete(); + _sample?.delete(); + _lastCropped?.delete(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(14.0))), + contentPadding: const EdgeInsets.only(top: 10.0), + title: Text("edit_profile_picture".i18n), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + child: _sample == null ? openImageWidget() : cropImageWidget(), + ), + if (user.user!.picture != "") + TextButton( + child: Text( + "remove_profile_picture".i18n, + style: const TextStyle(fontWeight: FontWeight.w500, color: Colors.red), + ), + onPressed: () { + user.user!.picture = ""; + Provider.of(context, listen: false).store.storeUser(user.user!); + Provider.of(context, listen: false).refresh(); + Navigator.of(context).pop(true); + }, + ), + ], + ), + actions: [ + TextButton( + child: Text( + "cancel".i18n, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + onPressed: () { + Navigator.of(context).maybePop(); + }, + ), + TextButton( + child: Text( + "done".i18n, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + onPressed: () async { + await _cropImage(); + Navigator.of(context).pop(true); + }, + ), + ], + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/settings/theme.dart b/filcnaplo_premium/lib/ui/mobile/settings/theme.dart new file mode 100644 index 0000000..18a1c44 --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/settings/theme.dart @@ -0,0 +1,657 @@ +import 'package:filcnaplo/models/settings.dart'; +import 'package:filcnaplo/theme/colors/accent.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo/theme/observer.dart'; +import 'package:filcnaplo/ui/widgets/grade/grade_tile.dart'; +import 'package:filcnaplo/ui/widgets/message/message_tile.dart'; +import 'package:filcnaplo_kreta_api/models/grade.dart'; +import 'package:filcnaplo_kreta_api/models/homework.dart'; +import 'package:filcnaplo_kreta_api/models/message.dart'; +import 'package:filcnaplo_mobile_ui/common/filter_bar.dart'; +import 'package:filcnaplo_mobile_ui/common/panel/panel.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/grade/new_grades.dart'; +import 'package:filcnaplo_mobile_ui/common/widgets/homework/homework_tile.dart'; +import 'package:filcnaplo_premium/models/premium_scopes.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/flutter_colorpicker/colorpicker.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/upsell.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'theme.i18n.dart'; + +class PremiumCustomAccentColorSetting extends StatefulWidget { + const PremiumCustomAccentColorSetting({Key? key}) : super(key: key); + + @override + State createState() => _PremiumCustomAccentColorSettingState(); +} + +enum CustomColorMode { theme, accent, background, highlight } + +class _PremiumCustomAccentColorSettingState extends State with TickerProviderStateMixin { + late final SettingsProvider settings; + bool colorSelection = false; + bool customColorMenu = false; + CustomColorMode colorMode = CustomColorMode.theme; + final customColorInput = TextEditingController(); + final unknownColor = Colors.black; + + late TabController _testTabController; + late TabController _colorsTabController; + late AnimationController _openAnimController; + + late final Animation backgroundAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _openAnimController, + curve: const Interval(0.2, 1.0, curve: Curves.easeInOut), + ), + ); + + late final Animation fullPageAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _openAnimController, + curve: const Interval(0.0, 0.6, curve: Curves.easeInOut), + ), + ); + + late final Animation backContainerAnimation = Tween(begin: 100, end: 0).animate( + CurvedAnimation( + parent: _openAnimController, + curve: const Interval(0.0, 0.9, curve: Curves.easeInOut), + ), + ); + + late final Animation backContentAnimation = Tween(begin: 100, end: 0).animate( + CurvedAnimation( + parent: _openAnimController, + curve: const Interval(0.2, 1.0, curve: Curves.easeInOut), + ), + ); + + late final Animation backContentScaleAnimation = Tween(begin: 0.8, end: 0.9).animate( + CurvedAnimation( + parent: _openAnimController, + curve: const Interval(0.45, 1.0, curve: Curves.easeInOut), + ), + ); + + late final Animation pickerContainerAnimation = Tween(begin: 0, end: 1).animate( + CurvedAnimation( + parent: _openAnimController, + curve: const Interval(0.25, 0.8, curve: Curves.easeInOut), + ), + ); + + @override + void initState() { + super.initState(); + _colorsTabController = TabController(length: 4, vsync: this); + _testTabController = TabController(length: 4, vsync: this); + settings = Provider.of(context, listen: false); + + _openAnimController = AnimationController(vsync: this, duration: const Duration(milliseconds: 750)); + _openAnimController.forward(); + } + + @override + void dispose() { + _openAnimController.dispose(); + super.dispose(); + } + + void setTheme(ThemeMode mode, bool store) async { + await settings.update(theme: mode, store: store); + Provider.of(context, listen: false).changeTheme(mode, updateNavbarColor: false); + } + + Color? getCustomColor() { + switch (colorMode) { + case CustomColorMode.theme: + return accentColorMap[settings.accentColor]; + case CustomColorMode.background: + return settings.customBackgroundColor; + case CustomColorMode.highlight: + return settings.customHighlightColor; + case CustomColorMode.accent: + return settings.customAccentColor; + } + } + + void updateCustomColor(Color v, bool store) { + if (colorMode != CustomColorMode.theme) settings.update(accentColor: AccentColor.custom, store: store); + switch (colorMode) { + case CustomColorMode.theme: + settings.update( + accentColor: accentColorMap.keys.firstWhere((element) => accentColorMap[element] == v, orElse: () => AccentColor.filc), store: store); + settings.update(customBackgroundColor: AppColors.of(context).background, store: store); + settings.update(customHighlightColor: AppColors.of(context).highlight, store: store); + settings.update(customAccentColor: v, store: store); + break; + case CustomColorMode.background: + settings.update(customBackgroundColor: v, store: store); + break; + case CustomColorMode.highlight: + settings.update(customHighlightColor: v, store: store); + break; + case CustomColorMode.accent: + settings.update(customAccentColor: v, store: store); + break; + } + } + + @override + Widget build(BuildContext context) { + bool hasAccess = Provider.of(context).hasScope(PremiumScopes.customColors); + bool isBackgroundDifferent = Theme.of(context).colorScheme.background != AppColors.of(context).background; + + ThemeMode currentTheme = Theme.of(context).brightness == Brightness.light ? ThemeMode.light : ThemeMode.dark; + + return WillPopScope( + onWillPop: () async { + Provider.of(context, listen: false).changeTheme(settings.theme, updateNavbarColor: true); + return true; + }, + child: AnimatedBuilder( + animation: _openAnimController, + builder: (context, child) { + final backgroundGradientBottomColor = isBackgroundDifferent + ? Theme.of(context).colorScheme.background + : HSVColor.fromColor(Theme.of(context).colorScheme.background) + .withValue(currentTheme == ThemeMode.dark ? 0.1 * _openAnimController.value : 1.0 - (0.1 * _openAnimController.value)) + .withAlpha(1.0) + .toColor(); + + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + systemNavigationBarColor: backgroundGradientBottomColor, + )); + + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + stops: const [0.0, 0.75], + colors: isBackgroundDifferent + ? [ + Theme.of(context) + .colorScheme + .background + .withOpacity(1 - ((currentTheme == ThemeMode.dark ? 0.65 : 0.45) * backgroundAnimation.value)), + backgroundGradientBottomColor, + ] + : [backgroundGradientBottomColor, backgroundGradientBottomColor], + ), + ), + child: Opacity( + opacity: fullPageAnimation.value, + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: AppBar( + surfaceTintColor: Theme.of(context).scaffoldBackgroundColor, + leading: BackButton(color: AppColors.of(context).text), + title: Text( + "Preview", + style: TextStyle(color: AppColors.of(context).text), + ), + backgroundColor: Colors.transparent, + elevation: 0, + ), + body: Stack( + children: [ + Opacity( + opacity: 1 - backContainerAnimation.value * (1 / 100), + child: Transform.translate( + offset: Offset(0, backContainerAnimation.value), + child: Container( + height: double.infinity, + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: const [ + 0.35, + 0.75 + ], colors: [ + Theme.of(context).colorScheme.background, + isBackgroundDifferent + ? HSVColor.fromColor(Theme.of(context).colorScheme.background) + .withSaturation( + (HSVColor.fromColor(Theme.of(context).colorScheme.background).saturation - 0.15).clamp(0.0, 1.0)) + .toColor() + : backgroundGradientBottomColor, + ]), + ), + margin: const EdgeInsets.symmetric(vertical: 30, horizontal: 20), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.only(top: 24.0), + child: Opacity( + opacity: 1 - backContentAnimation.value * (1 / 100), + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Transform.translate( + offset: Offset(0, -24 + backContentAnimation.value), + child: Transform.scale( + scale: backContentScaleAnimation.value, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 6.0), + child: FilterBar( + items: const [ + Tab(text: "All"), + Tab(text: "Grades"), + Tab(text: "Messages"), + Tab(text: "Absences"), + ], + controller: _testTabController, + padding: EdgeInsets.zero, + censored: true, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0, vertical: 8.0), + child: NewGradesSurprise( + [ + Grade.fromJson( + { + "Uid": "0,Ertekeles", + "RogzitesDatuma": "2022-01-01T23:00:00Z", + "KeszitesDatuma": "2022-01-01T23:00:00Z", + "LattamozasDatuma": null, + "Tantargy": { + "Uid": "0", + "Nev": "Filc szakirodalom", + "Kategoria": {"Uid": "0,_", "Nev": "_", "Leiras": "Nem mondom meg"}, + "SortIndex": 2 + }, + "Tema": "Kupak csomag vásárlás vizsga", + "Tipus": { + "Uid": "0,_", + "Nev": "_", + "Leiras": "Évközi jegy/értékelés", + }, + "Mod": { + "Uid": "0,_", + "Nev": "_", + "Leiras": "_ feladat", + }, + "ErtekFajta": { + "Uid": "1,Osztalyzat", + "Nev": "Osztalyzat", + "Leiras": "Elégtelen (1) és Jeles (5) között az öt alapértelmezett érték" + }, + "ErtekeloTanarNeve": "Premium", + "Jelleg": "Ertekeles", + "SzamErtek": 5, + "SzovegesErtek": "Jeles(5)", + "SulySzazalekErteke": 100, + "SzovegesErtekelesRovidNev": null, + "OsztalyCsoport": {"Uid": "0"}, + "SortIndex": 2 + }, + ), + ], + censored: true, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 6.0), + child: Panel( + child: GradeTile( + Grade.fromJson( + { + "Uid": "0,Ertekeles", + "RogzitesDatuma": "2022-01-01T23:00:00Z", + "KeszitesDatuma": "2022-01-01T23:00:00Z", + "LattamozasDatuma": null, + "Tantargy": { + "Uid": "0", + "Nev": "Filc szakosztály", + "Kategoria": {"Uid": "0,_", "Nev": "_", "Leiras": "Nem mondom meg"}, + "SortIndex": 2 + }, + "Tema": "Kupak csomag vásárlás vizsga", + "Tipus": { + "Uid": "0,_", + "Nev": "_", + "Leiras": "Évközi jegy/értékelés", + }, + "Mod": { + "Uid": "0,_", + "Nev": "_", + "Leiras": "_ feladat", + }, + "ErtekFajta": { + "Uid": "1,Osztalyzat", + "Nev": "Osztalyzat", + "Leiras": "Elégtelen (1) és Jeles (5) között az öt alapértelmezett érték" + }, + "ErtekeloTanarNeve": "Premium", + "Jelleg": "Ertekeles", + "SzamErtek": 5, + "SzovegesErtek": "Jeles(5)", + "SulySzazalekErteke": 100, + "SzovegesErtekelesRovidNev": null, + "OsztalyCsoport": {"Uid": "0"}, + "SortIndex": 2 + }, + ), + padding: EdgeInsets.zero, + censored: true, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 6.0), + child: Panel( + child: HomeworkTile( + Homework.fromJson( + { + "Uid": "0", + "Tantargy": { + "Uid": "0", + "Nev": "Filc premium előnyei", + "Kategoria": { + "Uid": "0,_", + "Nev": "_", + "Leiras": "Filc premium előnyei", + }, + "SortIndex": 0 + }, + "TantargyNeve": "Filc premium előnyei", + "RogzitoTanarNeve": "Kupak János", + "Szoveg": "45 perc filctollal való rajzolás", + "FeladasDatuma": "2022-01-01T23:00:00Z", + "HataridoDatuma": "2022-01-01T23:00:00Z", + "RogzitesIdopontja": "2022-01-01T23:00:00Z", + "IsTanarRogzitette": true, + "IsTanuloHaziFeladatEnabled": false, + "IsMegoldva": false, + "IsBeadhato": false, + "OsztalyCsoport": {"Uid": "0"}, + "IsCsatolasEngedelyezes": false + }, + ), + padding: EdgeInsets.zero, + censored: true, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 6.0), + child: Panel( + child: MessageTile( + Message.fromJson( + { + "azonosito": 0, + "isElolvasva": true, + "isToroltElem": false, + "tipus": { + "azonosito": 1, + "kod": "BEERKEZETT", + "rovidNev": "Beérkezett üzenet", + "nev": "Beérkezett üzenet", + "leiras": "Beérkezett üzenet" + }, + "uzenet": { + "azonosito": 0, + "kuldesDatum": "2022-01-01T23:00:00", + "feladoNev": "Filc Napló", + "feladoTitulus": "Nagyon magas szintű személy", + "szoveg": + "

Kedves Felhasználó!


A prémium vásárlásakor kapott filctollal 90%-al több esély van jó jegyek szerzésére.

", + "targy": "Filctoll használati útmutató", + "statusz": { + "azonosito": 2, + "kod": "KIKULDVE", + "rovidNev": "Kiküldve", + "nev": "Kiküldve", + "leiras": "Kiküldve" + }, + "cimzettLista": [ + { + "azonosito": 0, + "kretaAzonosito": 0, + "nev": "Tinta Józsi", + "tipus": { + "azonosito": 0, + "kod": "TANULO", + "rovidNev": "Tanuló", + "nev": "Tanuló", + "leiras": "Tanuló" + } + }, + ], + "csatolmanyok": [ + {"azonosito": 0, "fajlNev": "Filctoll.doc"} + ] + } + }, + ), + censored: true, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Wrap( + children: [ + Opacity( + opacity: pickerContainerAnimation.value, + child: SizedBox( + width: double.infinity, + child: Container( + padding: const EdgeInsets.only(bottom: 12.0), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: backgroundGradientBottomColor, + offset: const Offset(0, -4), + blurRadius: 16, + spreadRadius: 12, + ), + ], + gradient: LinearGradient(begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: const [ + 0.0, + 0.175 + ], colors: [ + backgroundGradientBottomColor, + backgroundGradientBottomColor, + ]), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: FilterBar( + items: [ + ColorTab( + color: accentColorMap[settings.accentColor] ?? unknownColor, + tab: Tab(text: "colorpicker_presets".i18n)), + ColorTab( + unlocked: hasAccess, + color: settings.customBackgroundColor ?? unknownColor, + tab: Tab(text: "colorpicker_background".i18n)), + ColorTab( + unlocked: hasAccess, + color: settings.customHighlightColor ?? unknownColor, + tab: Tab(text: "colorpicker_panels".i18n)), + ColorTab( + unlocked: hasAccess, + color: settings.customAccentColor ?? unknownColor, + tab: Tab(text: "colorpicker_accent".i18n)), + ], + onTap: (index) { + if (!hasAccess) { + index = 0; + _colorsTabController.animateTo(0, duration: Duration.zero); + + PremiumLockedFeatureUpsell.show(context: context, feature: PremiumFeature.customcolors); + } + + switch (index) { + case 0: + setState(() { + colorMode = CustomColorMode.theme; + }); + break; + case 1: + setState(() { + colorMode = CustomColorMode.background; + }); + break; + case 2: + setState(() { + colorMode = CustomColorMode.highlight; + }); + break; + case 3: + setState(() { + colorMode = CustomColorMode.accent; + }); + break; + } + }, + controller: _colorsTabController, + padding: EdgeInsets.zero, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: SafeArea( + child: FilcColorPicker( + colorMode: colorMode, + pickerColor: colorMode == CustomColorMode.accent + ? settings.customAccentColor ?? unknownColor + : colorMode == CustomColorMode.background + ? settings.customBackgroundColor ?? unknownColor + : colorMode == CustomColorMode.theme + ? (accentColorMap[settings.accentColor] ?? AppColors.of(context).text) // idk what else + : settings.customHighlightColor ?? unknownColor, + onColorChanged: (c) { + setState(() { + updateCustomColor(c, false); + }); + setTheme(settings.theme, false); + }, + onColorChangeEnd: (c, {adaptive}) { + setState(() { + if (adaptive == true) { + settings.update(accentColor: AccentColor.adaptive); + settings.update(customBackgroundColor: AppColors.of(context).background, store: true); + settings.update(customHighlightColor: AppColors.of(context).highlight, store: true); + } else { + updateCustomColor(c, true); + } + }); + setTheme(settings.theme, true); + }, + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} + +class ColorTab extends StatelessWidget { + const ColorTab({Key? key, required this.tab, required this.color, this.unlocked = true}) : super(key: key); + + final Tab tab; + final Color color; + final bool unlocked; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Transform.translate( + offset: const Offset(-3, 1), + child: unlocked + ? Container( + width: 15, + height: 15, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: color, + border: Border.all(color: Colors.black, width: 2.0), + ), + ) + : const Padding( + padding: EdgeInsets.symmetric(horizontal: 2), + child: Icon(Icons.lock, color: Color.fromARGB(255, 82, 82, 82), size: 18), + ), + ), + tab + ], + ); + } +} + +class PremiumColorPickerItem extends StatelessWidget { + const PremiumColorPickerItem({Key? key, required this.label, this.onTap, required this.color}) : super(key: key); + + final String label; + final void Function()? onTap; + final Color color; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: InkWell( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 24.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + label, + style: TextStyle(color: AppColors.of(context).text, fontWeight: FontWeight.w500), + ), + ), + Container( + width: 30, + height: 30, + decoration: BoxDecoration(color: color, shape: BoxShape.circle, border: Border.all()), + ), + ], + ), + ), + onTap: onTap, + ), + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/settings/theme.i18n.dart b/filcnaplo_premium/lib/ui/mobile/settings/theme.i18n.dart new file mode 100644 index 0000000..6f5bee7 --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/settings/theme.i18n.dart @@ -0,0 +1,33 @@ +import 'package:i18n_extension/i18n_extension.dart'; + +extension SettingsLocalization on String { + static final _t = Translations.byLocale("hu_hu") + + { + "en_en": { + "colorpicker_presets": "Presets", + "colorpicker_background": "Background", + "colorpicker_panels": "Panels", + "colorpicker_accent": "Accent", + "need_sub": "You need Kupak subscription to use modify this.", + }, + "hu_hu": { + "colorpicker_presets": "Téma", + "colorpicker_background": "Háttér", + "colorpicker_panels": "Panelek", + "colorpicker_accent": "Színtónus", + "need_sub": "A módosításhoz Kupak szintű támogatás szükséges.", + }, + "de_de": { + "colorpicker_presets": "Farben", + "colorpicker_background": "Hintergrund", + "colorpicker_panels": "Tafeln", + "colorpicker_accent": "Akzent", + "need_sub": "Sie benötigen ein Kupak-Abonnement, um diese Funktion zu ändern.", + }, + }; + + String get i18n => localize(this, _t); + String fill(List params) => localizeFill(this, params); + String plural(int value) => localizePlural(value, this, _t); + String version(Object modifier) => localizeVersion(modifier, this, _t); +} diff --git a/filcnaplo_premium/lib/ui/mobile/timetable/fs_timetable.dart b/filcnaplo_premium/lib/ui/mobile/timetable/fs_timetable.dart new file mode 100644 index 0000000..b0582d2 --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/timetable/fs_timetable.dart @@ -0,0 +1,179 @@ +import 'package:filcnaplo/helpers/subject.dart'; +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/controllers/timetable_controller.dart'; +import 'package:filcnaplo_mobile_ui/common/empty.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:filcnaplo/utils/format.dart'; +import 'dart:math' as math; +import 'package:intl/intl.dart'; +import 'package:i18n_extension/i18n_widget.dart'; + +class PremiumFSTimetable extends StatefulWidget { + const PremiumFSTimetable({Key? key, required this.controller}) : super(key: key); + + final TimetableController controller; + + @override + State createState() => _PremiumFSTimetableState(); +} + +class _PremiumFSTimetableState extends State { + @override + void initState() { + super.initState(); + + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive); + SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + systemNavigationBarColor: Colors.transparent, + )); + } + + @override + Widget build(BuildContext context) { + if (widget.controller.days == null || widget.controller.days!.isEmpty) { + return const Center(child: Empty()); + } + + final days = widget.controller.days!; + final everyLesson = days.expand((x) => x).toList(); + everyLesson.sort((a, b) => a.start.compareTo(b.start)); + + final int maxLessonCount = days.fold(0, (a, b) => math.max(a, b.where((l) => l.subject.id != "" || l.isEmpty).length)); + + final int minIndex = int.tryParse(everyLesson.first.lessonIndex) ?? 0; + final int maxIndex = int.tryParse(everyLesson.last.lessonIndex) ?? maxLessonCount; + + const prefixw = 40; + const padding = prefixw + 6 * 2; + final colw = (MediaQuery.of(context).size.width - padding) / days.length; + + return Scaffold( + body: ListView.builder( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 6.0, vertical: 24.0), + itemCount: maxIndex + 1, + itemBuilder: (context, index) { + List columns = []; + + for (int dayIndex = -1; dayIndex < days.length; dayIndex++) { + final dayOffset = dayIndex == -1 ? 0 : (int.tryParse(days[dayIndex].first.lessonIndex) ?? 0) - minIndex; + final lessonIndex = index - 1; + + if (dayIndex == -1) { + if (lessonIndex >= 0) { + columns.add(SizedBox( + width: prefixw.toDouble(), + height: 40.0, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Text( + "${minIndex + lessonIndex}.", + style: TextStyle(fontWeight: FontWeight.bold, color: Theme.of(context).colorScheme.secondary), + ), + ), + )); + } else { + columns.add(SizedBox(width: prefixw.toDouble())); + } + continue; + } + + final lessons = days[dayIndex].where((l) => l.subject.id != "" || l.isEmpty).toList(); + + if (lessons.isEmpty) continue; + if (lessonIndex >= lessons.length) continue; + + if (dayIndex >= days.length || (lessonIndex + dayOffset) >= lessons.length) { + columns.add(SizedBox(width: colw)); + continue; + } + + if (lessonIndex == -1 && dayIndex >= 0) { + columns.add(SizedBox( + width: colw, + height: 40.0, + child: Text( + DateFormat("EEEE", I18n.of(context).locale.languageCode).format(lessons.first.date).capital(), + style: const TextStyle(fontSize: 24.0, fontWeight: FontWeight.bold), + ), + )); + continue; + } + + if (lessons[lessonIndex].isEmpty) { + columns.add(SizedBox( + width: colw, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(FeatherIcons.slash, size: 18.0, color: AppColors.of(context).text.withOpacity(.3)), + const SizedBox(width: 8.0), + Text( + "Lyukas óra", + style: TextStyle(color: AppColors.of(context).text.withOpacity(.3)), + ), + ], + ), + )); + continue; + } + + if (dayOffset > 0 && lessonIndex < dayOffset) { + columns.add(SizedBox(width: colw)); + continue; + } + + columns.add(SizedBox( + width: colw, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + SubjectIcon.resolveVariant(context: context, subject: lessons[lessonIndex - dayOffset].subject), + size: 18.0, + color: AppColors.of(context).text.withOpacity(.7), + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + lessons[lessonIndex - dayOffset].subject.renamedTo ?? lessons[lessonIndex - dayOffset].subject.name.capital(), + maxLines: 1, + style: TextStyle(fontStyle: lessons[lessonIndex - dayOffset].subject.isRenamed ? FontStyle.italic : null), + overflow: TextOverflow.clip, + softWrap: false, + ), + ), + const SizedBox(width: 15), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 26.0), + child: Text( + lessons[lessonIndex - dayOffset].room, + style: TextStyle(color: AppColors.of(context).text.withOpacity(.5), overflow: TextOverflow.ellipsis), + ), + ), + ], + ), + )); + } + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: columns, + ); + }, + ), + ); + } +} diff --git a/filcnaplo_premium/lib/ui/mobile/timetable/fs_timetable_button.dart b/filcnaplo_premium/lib/ui/mobile/timetable/fs_timetable_button.dart new file mode 100644 index 0000000..69f8b3f --- /dev/null +++ b/filcnaplo_premium/lib/ui/mobile/timetable/fs_timetable_button.dart @@ -0,0 +1,45 @@ +import 'package:filcnaplo/theme/colors/colors.dart'; +import 'package:filcnaplo_kreta_api/controllers/timetable_controller.dart'; +import 'package:filcnaplo_mobile_ui/common/system_chrome.dart'; +import 'package:filcnaplo_premium/models/premium_scopes.dart'; +import 'package:filcnaplo_premium/providers/premium_provider.dart'; +import 'package:filcnaplo_premium/ui/mobile/premium/upsell.dart'; +import 'package:filcnaplo_premium/ui/mobile/timetable/fs_timetable.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_feather_icons/flutter_feather_icons.dart'; +import 'package:provider/provider.dart'; + +class PremiumFSTimetableButton extends StatelessWidget { + const PremiumFSTimetableButton({Key? key, required this.controller}) : super(key: key); + + final TimetableController controller; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: IconButton( + splashRadius: 24.0, + onPressed: () { + if (!Provider.of(context, listen: false).hasScope(PremiumScopes.fsTimetable)) { + PremiumLockedFeatureUpsell.show(context: context, feature: PremiumFeature.weeklytimetable); + return; + } + + Navigator.of(context, rootNavigator: true) + .push(PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => PremiumFSTimetable( + controller: controller, + ), + )) + .then((_) { + SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]); + setSystemChrome(context); + }); + }, + icon: Icon(FeatherIcons.trello, color: AppColors.of(context).text), + ), + ); + } +} diff --git a/filcnaplo_premium/pubspec.yaml b/filcnaplo_premium/pubspec.yaml new file mode 100644 index 0000000..3ce02a3 --- /dev/null +++ b/filcnaplo_premium/pubspec.yaml @@ -0,0 +1,36 @@ +name: filcnaplo_premium +publish_to: "none" + +environment: + sdk: ">=2.17.0 <3.0.0" + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.2 + + # Filcnaplo main dep + filcnaplo: + path: ../filcnaplo/ + filcnaplo_kreta_api: + path: ../filcnaplo_kreta_api/ + filcnaplo_mobile_ui: + path: "../filcnaplo_mobile_ui/" + + provider: ^5.0.0 + flutter_feather_icons: ^2.0.0+1 + uni_links: ^0.5.1 + url_launcher: ^6.1.6 + dropdown_button2: ^1.8.9 + home_widget: ^0.1.6 + image_picker: ^0.8.6 + image_crop: ^0.4.1 + lottie: ^1.4.3 + animations: ^2.0.1 + flutter_svg: ^1.1.6 + +dev_dependencies: + flutter_lints: ^1.0.0 + +flutter: + uses-material-design: true