From b1c11ba21fdbf00ff63f5405d66ae25d9e7f48d2 Mon Sep 17 00:00:00 2001 From: JHubi1 Date: Tue, 3 Sep 2024 21:44:03 +0200 Subject: [PATCH] Updates for web --- lib/l10n/app_en.arb | 25 ++++++++++++- lib/main.dart | 74 +++++++++++++++++++++++++++++++++---- lib/settings/interface.dart | 18 +++++++-- lib/worker/setter.dart | 72 +++++++++++++++++++++++++++++------- lib/worker/theme.dart | 46 ++++++++++++----------- pubspec.lock | 16 ++++++++ pubspec.yaml | 1 + untranslated_messages.json | 20 ++++++++++ web/index.html | 14 +++++++ 9 files changed, 238 insertions(+), 48 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 832870d..a0d107f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -15,6 +15,11 @@ "description": "Text displayed for settings option", "context": "Visible in the side bar" }, + "optionInstallPwa": "Install Webapp", + "@optionInstallPwa": { + "description": "Text displayed for install PWA option", + "context": "Visible in the side bar" + }, "optionNoChatFound": "No chats found", "@optionNoChatFound": { "description": "Text displayed when no chats are found", @@ -160,6 +165,22 @@ "description": "Text displayed when the model name is invalid", "context": "Visible in the model dialog" }, + "modelDialogAddAllowanceTitle": "Allow Proxy", + "modelDialogAddAllowanceDescription": "Ollama App must check if the entered model is valid. For that, we normally send a web request to the Ollama model list and check the status code, but because you're using the web client, we can't do that directly. Instead, the app will send the request to a different api, hosted by JHubi1, to check for us.\nThis is a one-time request and will only be sent when you add a new model.\nYour IP address will be sent with the request and might be stored for up to ten minutes to prevent spamming with potential harmful intentions.\nIf you accept, your selection will be remembered in the future; if not, nothing will be sent and the model won't be added.", + "@modelDialogAddAllowanceDescription": { + "description": "Description of the allow proxy dialog", + "context": "Visible in the model dialog" + }, + "modelDialogAddAllowanceAllow": "Allow", + "@modelDialogAddAllowanceAllow": { + "description": "Text displayed for allow button, should be capitalized", + "context": "Visible in the model dialog" + }, + "modelDialogAddAllowanceDeny": "Deny", + "@modelDialogAddAllowanceDeny": { + "description": "Text displayed for deny button, should be capitalized", + "context": "Visible in the model dialog" + }, "modelDialogAddAssuranceTitle": "Add {model}?", "@modelDialogAddAssuranceTitle": { "description": "Title of the add model assurance dialog", @@ -363,14 +384,14 @@ "description": "Text displayed when the host is being checked", "context": "Visible in the settings view" }, - "settingsHostInvalid": "Issue: {type, select, url{Invalid URL} host{Invalid Host} timeout{Request Failed. Server issues} other{Request Failed}}", + "settingsHostInvalid": "Issue: {type, select, url{Invalid URL} host{Invalid Host} timeout{Request Failed. Server issues} ratelimit{Too many requests} other{Request Failed}}", "@settingsHostInvalid": { "description": "Text displayed when the host is invalid", "context": "Visible in the settings view", "placeholders": { "type": { "type": "String", - "description": "Type of the issue, either 'url' or 'other' (preferably 'host')" + "description": "Type of the issue, either 'url', 'host', 'timeout', 'ratelimit' or 'other' (preferably 'host')" } } }, diff --git a/lib/main.dart b/lib/main.dart index e15f264..35097bd 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,6 +4,7 @@ import 'dart:math'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; @@ -35,6 +36,8 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:version/version.dart'; +import 'package:pwa_install/pwa_install.dart' as pwa; +import 'package:universal_html/html.dart' as html; // client configuration @@ -89,6 +92,10 @@ void Function(void Function())? setGlobalState; void Function(void Function())? setMainAppState; void main() { + pwa.PWAInstall().setup(installCallback: () { + debugPrint('APP INSTALLED!'); + }); + runApp(const App()); if (desktopFeature()) { @@ -195,7 +202,9 @@ class _MainAppState extends State { left: desktopLayoutRequired(context) ? 17 : 12, right: desktopLayoutRequired(context) ? 17 : 12); return List.from([ - desktopFeature() ? const SizedBox(height: 8) : const SizedBox.shrink(), + (desktopLayoutNotRequired(context) || kIsWeb) + ? const SizedBox(height: 8) + : const SizedBox.shrink(), desktopLayoutNotRequired(context) ? const SizedBox.shrink() : (Padding( @@ -324,6 +333,47 @@ class _MainAppState extends State { const SizedBox(width: 16), ]))))) : const SizedBox.shrink(), + (pwa.PWAInstall().installPromptEnabled && + pwa.PWAInstall().launchMode == pwa.LaunchMode.browser) + ? (Padding( + padding: padding, + child: InkWell( + enableFeedback: false, + customBorder: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(50))), + onTap: () { + selectionHaptic(); + if (!desktopLayout(context)) { + Navigator.of(context).pop(); + } + pwa.PWAInstall().onAppInstalled = () { + WidgetsBinding.instance.addPostFrameCallback((_) { + pwa.setLaunchModePWA(); + setMainAppState!(() {}); + }); + }; + pwa.PWAInstall().promptInstall_(); + setState(() {}); + }, + child: Padding( + padding: const EdgeInsets.only(top: 16, bottom: 16), + child: Row(children: [ + Padding( + padding: const EdgeInsets.only(left: 16, right: 12), + child: desktopLayoutNotRequired(context) + ? const Icon(Icons.install_desktop_rounded) + : const Icon(Icons.install_mobile_rounded)), + Expanded( + child: Text( + AppLocalizations.of(context)!.optionInstallPwa, + softWrap: false, + overflow: TextOverflow.fade, + style: + const TextStyle(fontWeight: FontWeight.w500)), + ), + const SizedBox(width: 16), + ]))))) + : const SizedBox.shrink(), (desktopLayoutNotRequired(context) && (!allowMultipleChats && !allowSettings)) ? const SizedBox.shrink() @@ -437,7 +487,8 @@ class _MainAppState extends State { } }); }, - onLongPress: desktopFeature() + onLongPress: (desktopFeature() || + (kIsWeb && desktopLayoutNotRequired(context))) ? null : () async { selectionHaptic(); @@ -486,7 +537,10 @@ class _MainAppState extends State { AnimatedSwitcher( duration: const Duration(milliseconds: 100), child: - ((desktopFeature() && + (((desktopFeature() || + (kIsWeb && + desktopLayoutNotRequired( + context))) && (hoveredChat == jsonDecode(item)["uuid"])) || !allowMultipleChats) @@ -643,7 +697,9 @@ class _MainAppState extends State { )) : const SizedBox(width: 16)), ])))); - return desktopFeature() || !allowMultipleChats + return (desktopFeature() || + (kIsWeb && desktopLayoutNotRequired(context))) || + !allowMultipleChats ? child : Dismissible( key: Key(jsonDecode(item)["uuid"]), @@ -689,6 +745,10 @@ class _MainAppState extends State { super.initState(); mainContext = context; + if (kIsWeb) { + html.querySelector(".loader")?.remove(); + } + WidgetsBinding.instance.addPostFrameCallback( (_) async { if (prefs == null) { @@ -1529,7 +1589,7 @@ class _MainAppState extends State { inputTextColor: themeLight().colorScheme.onSurface, inputBorderRadius: BorderRadius.circular(32), inputPadding: const EdgeInsets.all(16), - inputMargin: EdgeInsets.only(left: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !desktopFeature()) ? 8 : 6, right: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !desktopFeature()) ? 8 : 6, bottom: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !desktopFeature()) ? 0 : 8), + inputMargin: EdgeInsets.only(left: !desktopFeature(web: true) ? 8 : 6, right: !desktopFeature(web: true) ? 8 : 6, bottom: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !desktopFeature(web: true)) ? 0 : 8), messageMaxWidth: (MediaQuery.of(context).size.width >= 1000) ? (MediaQuery.of(context).size.width >= 1600) ? (MediaQuery.of(context).size.width >= 2200) @@ -1567,9 +1627,9 @@ class _MainAppState extends State { attachmentButtonMargin: EdgeInsets.zero, inputBackgroundColor: themeDark().colorScheme.onSurface.withAlpha(40), inputTextColor: themeDark().colorScheme.onSurface, - inputBorderRadius: const BorderRadius.all(Radius.circular(64)), + inputBorderRadius: BorderRadius.circular(32), inputPadding: const EdgeInsets.all(16), - inputMargin: EdgeInsets.only(left: 8, right: 8, bottom: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !desktopFeature()) ? 0 : 8), + inputMargin: EdgeInsets.only(left: !desktopFeature(web: true) ? 8 : 6, right: !desktopFeature(web: true) ? 8 : 6, bottom: (MediaQuery.of(context).viewInsets.bottom == 0.0 && !desktopFeature(web: true)) ? 0 : 8), messageMaxWidth: (MediaQuery.of(context).size.width >= 1000) ? (MediaQuery.of(context).size.width >= 1600) ? (MediaQuery.of(context).size.width >= 2200) diff --git a/lib/settings/interface.dart b/lib/settings/interface.dart index cda3189..b6d5d82 100644 --- a/lib/settings/interface.dart +++ b/lib/settings/interface.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/foundation.dart'; import '../main.dart'; import '../worker/haptic.dart'; @@ -313,8 +312,19 @@ class _ScreenSettingsInterfaceState extends State { Icons.calculate_rounded, null, onlyDesktopDescription: false, - description: - "\n${((prefs!.getDouble("timeoutMultiplier") ?? 1) == 10) ? "${(prefs!.getDouble("timeoutMultiplier") ?? 1).round()}." : (prefs!.getDouble("timeoutMultiplier") ?? 1)} x 30s = ${((prefs!.getDouble("timeoutMultiplier") ?? 1) * 30).round()}s ${secondsBeautify((prefs!.getDouble("timeoutMultiplier") ?? 1) * 30)}"), + // making it complicated because web is weird and doesn't like to round numbers + description: "\n${() { + var value = + (prefs!.getDouble("timeoutMultiplier") ?? 1); + if (value == 10) { + return "${value.round()}."; + } else { + if (!value.toString().contains(".")) { + return "${value.toString()}.0"; + } + return value; + } + }.call()} x 30s = ${((prefs!.getDouble("timeoutMultiplier") ?? 1) * 30).round()}s ${secondsBeautify((prefs!.getDouble("timeoutMultiplier") ?? 1) * 30)}"), titleDivider(context: context), toggle( context, @@ -368,7 +378,7 @@ class _ScreenSettingsInterfaceState extends State { setState(() {}); }), const SizedBox(height: 8), - !kIsWeb + (colorSchemeLight != null && colorSchemeDark != null) ? SegmentedButton( segments: [ ButtonSegment( diff --git a/lib/worker/setter.dart b/lib/worker/setter.dart index 2fe4ea8..c71e725 100644 --- a/lib/worker/setter.dart +++ b/lib/worker/setter.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'desktop.dart'; @@ -187,11 +188,8 @@ void setModel(BuildContext context, Function setState) { scrollDirection: Axis.vertical, child: Wrap( spacing: desktopLayout(context) ? 10.0 : 5.0, - runSpacing: desktopFeature() - ? desktopFeature() - ? 10.0 - : 5.0 - : 0.0, + runSpacing: + desktopFeature(web: true) ? 10.0 : 0.0, alignment: WrapAlignment.center, children: List.generate( models.length, @@ -280,7 +278,7 @@ void setModel(BuildContext context, Function setState) { ]))); }); - if (desktopFeature()) { + if (desktopLayoutNotRequired(context)) { showDialog( context: context, builder: (context) { @@ -312,6 +310,7 @@ void addModel(BuildContext context, Function setState) async { baseUrl: "$host/api"); bool canceled = false; bool networkError = false; + bool ratelimitError = false; bool alreadyExists = false; final String invalidText = AppLocalizations.of(context)!.modelDialogAddPromptInvalid; @@ -319,6 +318,8 @@ void addModel(BuildContext context, Function setState) async { AppLocalizations.of(context)!.settingsHostInvalid("other"); final timeoutErrorText = AppLocalizations.of(context)!.settingsHostInvalid("timeout"); + final ratelimitErrorText = + AppLocalizations.of(context)!.settingsHostInvalid("ratelimit"); final alreadyExistsText = AppLocalizations.of(context)!.modelDialogAddPromptAlreadyExists; final downloadSuccessText = @@ -337,6 +338,7 @@ void addModel(BuildContext context, Function setState) async { if (model == "") return false; canceled = false; networkError = false; + ratelimitError = false; alreadyExists = false; try { var request = await client.listModels().timeout(Duration( @@ -353,11 +355,48 @@ void addModel(BuildContext context, Function setState) async { networkError = true; return false; } + var endpoint = "https://ollama.com/library/"; + if (kIsWeb) { + if (!(prefs!.getBool("allowWebProxy") ?? false)) { + bool returnValue = false; + await showDialog( + context: mainContext!, + barrierDismissible: false, + builder: (context) { + return AlertDialog( + title: Text(AppLocalizations.of(context)! + .modelDialogAddAllowanceTitle), + content: SizedBox( + width: 640, + child: Text(AppLocalizations.of(context)! + .modelDialogAddAllowanceDescription), + ), + actions: [ + TextButton( + onPressed: () { + canceled = true; + Navigator.of(context).pop(); + }, + child: Text(AppLocalizations.of(context)! + .modelDialogAddAllowanceDeny)), + TextButton( + onPressed: () { + returnValue = true; + Navigator.of(context).pop(); + }, + child: Text(AppLocalizations.of(context)! + .modelDialogAddAllowanceAllow)) + ]); + }); + if (!returnValue) return false; + prefs!.setBool("allowWebProxy", true); + } + endpoint = "https://end.jhubi1.com/ollama-proxy/"; + } http.Response response; try { - response = await http - .get(Uri.parse("https://ollama.com/library/$model")) - .timeout(Duration( + response = await http.get(Uri.parse("$endpoint$model")).timeout( + Duration( seconds: (10.0 * (prefs!.getDouble("timeoutMultiplier") ?? 1.0)) .round())); } catch (_) { @@ -376,8 +415,11 @@ void addModel(BuildContext context, Function setState) async { return AlertDialog( title: Text(AppLocalizations.of(context)! .modelDialogAddAssuranceTitle(model)), - content: Text(AppLocalizations.of(context)! - .modelDialogAddAssuranceDescription(model)), + content: SizedBox( + width: 640, + child: Text(AppLocalizations.of(context)! + .modelDialogAddAssuranceDescription(model)), + ), actions: [ TextButton( onPressed: () { @@ -398,10 +440,14 @@ void addModel(BuildContext context, Function setState) async { resetSystemNavigation(mainContext!); return returnValue; } + if (response.statusCode == 429) { + ratelimitError = true; + } return false; }, validatorErrorCallback: (content) { if (networkError) return networkErrorText; + if (ratelimitError) return ratelimitErrorText; if (alreadyExists) return alreadyExistsText; if (canceled) return null; return invalidText; @@ -774,8 +820,8 @@ Future prompt(BuildContext context, left: 16, right: 16, top: 16, - bottom: desktopFeature() - ? 16 + bottom: desktopFeature(web: true) + ? 12 : MediaQuery.of(context).viewInsets.bottom), width: double.infinity, child: Column( diff --git a/lib/worker/theme.dart b/lib/worker/theme.dart index b2827f8..ceb0ce4 100644 --- a/lib/worker/theme.dart +++ b/lib/worker/theme.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../main.dart'; @@ -11,29 +12,12 @@ void resetSystemNavigation(BuildContext context, Color? statusBarColor, Color? systemNavigationBarColor, Duration? delay}) { - ColorScheme getColorScheme() { - final ColorScheme schemeLight = themeLight().colorScheme; - final ColorScheme schemeDark = themeDark().colorScheme; - if (themeMode() == ThemeMode.system) { - if (MediaQuery.of(context).platformBrightness == Brightness.light) { - return schemeLight; - } else { - return schemeDark; - } - } else { - if (themeMode() == ThemeMode.light) { - return schemeLight; - } else { - return schemeDark; - } - } - } - WidgetsBinding.instance.addPostFrameCallback((_) async { if (delay != null) { await Future.delayed(delay); } - color ??= getColorScheme().surface; + // ignore: use_build_context_synchronously + color ??= themeCurrent(context).colorScheme.surface; SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( statusBarIconBrightness: (((statusBarColor != null) ? statusBarColor : color)! @@ -42,8 +26,10 @@ void resetSystemNavigation(BuildContext context, ? Brightness.dark : Brightness.light, statusBarColor: - (((statusBarColor != null) ? statusBarColor : color)!.value != - getColorScheme().surface.value) + ((((statusBarColor != null) ? statusBarColor : color)!.value != + // ignore: use_build_context_synchronously + themeCurrent(context).colorScheme.surface.value) || + kIsWeb) ? (statusBarColor != null) ? statusBarColor : color @@ -56,7 +42,7 @@ void resetSystemNavigation(BuildContext context, ThemeData themeModifier(ThemeData theme) { return theme.copyWith( - // https://docs.flutter.dev/platform-integration/android/predictive-back#set-up-your-app + // https://docs.flutter.dev/platform-integration/android/predictive-back#set-up-your-app pageTransitionsTheme: const PageTransitionsTheme( builders: { TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), @@ -64,6 +50,22 @@ ThemeData themeModifier(ThemeData theme) { )); } +ThemeData themeCurrent(BuildContext context) { + if (themeMode() == ThemeMode.system) { + if (MediaQuery.of(context).platformBrightness == Brightness.light) { + return themeLight(); + } else { + return themeDark(); + } + } else { + if (themeMode() == ThemeMode.light) { + return themeLight(); + } else { + return themeDark(); + } + } +} + ThemeData themeLight() { if (!(prefs?.getBool("useDeviceTheme") ?? false) || colorSchemeLight == null) { diff --git a/pubspec.lock b/pubspec.lock index 1a5de96..9957f55 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -509,6 +509,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.19.0" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" json_annotation: dependency: transitive description: @@ -733,6 +741,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pwa_install: + dependency: "direct main" + description: + name: pwa_install + sha256: "79a6c38e67db12da98489258ead7e025ce4860c9010cde226d0b53ad6d403fd9" + url: "https://pub.dev" + source: hosted + version: "0.0.5" scroll_to_index: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 99a468c..e233fc2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: dynamic_color: ^1.7.0 volume_controller: ^2.0.7 universal_html: ^2.2.4 + pwa_install: ^0.0.5 dev_dependencies: flutter_test: diff --git a/untranslated_messages.json b/untranslated_messages.json index 439a607..19fc532 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -1,5 +1,6 @@ { "de": [ + "optionInstallPwa", "deleteChat", "renameChat", "tooltipReset", @@ -7,6 +8,10 @@ "modelDialogAddPromptDescription", "modelDialogAddPromptAlreadyExists", "modelDialogAddPromptInvalid", + "modelDialogAddAllowanceTitle", + "modelDialogAddAllowanceDescription", + "modelDialogAddAllowanceAllow", + "modelDialogAddAllowanceDeny", "modelDialogAddAssuranceTitle", "modelDialogAddAssuranceDescription", "modelDialogAddAssuranceAdd", @@ -41,6 +46,7 @@ ], "it": [ + "optionInstallPwa", "deleteChat", "renameChat", "tooltipReset", @@ -48,6 +54,10 @@ "modelDialogAddPromptDescription", "modelDialogAddPromptAlreadyExists", "modelDialogAddPromptInvalid", + "modelDialogAddAllowanceTitle", + "modelDialogAddAllowanceDescription", + "modelDialogAddAllowanceAllow", + "modelDialogAddAllowanceDeny", "modelDialogAddAssuranceTitle", "modelDialogAddAssuranceDescription", "modelDialogAddAssuranceAdd", @@ -82,6 +92,7 @@ ], "tr": [ + "optionInstallPwa", "deleteChat", "renameChat", "tooltipReset", @@ -89,6 +100,10 @@ "modelDialogAddPromptDescription", "modelDialogAddPromptAlreadyExists", "modelDialogAddPromptInvalid", + "modelDialogAddAllowanceTitle", + "modelDialogAddAllowanceDescription", + "modelDialogAddAllowanceAllow", + "modelDialogAddAllowanceDeny", "modelDialogAddAssuranceTitle", "modelDialogAddAssuranceDescription", "modelDialogAddAssuranceAdd", @@ -123,6 +138,7 @@ ], "zh": [ + "optionInstallPwa", "deleteChat", "renameChat", "tooltipReset", @@ -130,6 +146,10 @@ "modelDialogAddPromptDescription", "modelDialogAddPromptAlreadyExists", "modelDialogAddPromptInvalid", + "modelDialogAddAllowanceTitle", + "modelDialogAddAllowanceDescription", + "modelDialogAddAllowanceAllow", + "modelDialogAddAllowanceDeny", "modelDialogAddAssuranceTitle", "modelDialogAddAssuranceDescription", "modelDialogAddAssuranceAdd", diff --git a/web/index.html b/web/index.html index f0ae615..a8d69d9 100644 --- a/web/index.html +++ b/web/index.html @@ -7,6 +7,7 @@ + @@ -91,6 +92,19 @@ } } +