import 'dart:convert'; import 'dart:io'; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'main.dart'; import 'package:ollama_app/worker_setter.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:dartx/dartx.dart'; import 'package:http/http.dart' as http; import 'package:simple_icons/simple_icons.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:restart_app/restart_app.dart'; import 'package:file_picker/file_picker.dart'; import 'package:intl/intl.dart'; import 'package:install_referrer/install_referrer.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:version/version.dart'; class ScreenSettings extends StatefulWidget { const ScreenSettings({super.key}); @override State createState() => _ScreenSettingsState(); } class _ScreenSettingsState extends State { final hostInputController = TextEditingController( text: (useHost) ? fixedHost : (prefs?.getString("host") ?? "http://localhost:11434")); bool hostLoading = false; bool hostInvalidUrl = false; bool hostInvalidHost = false; void checkHost() async { setState(() { hostLoading = true; hostInvalidUrl = false; hostInvalidHost = false; }); var tmpHost = hostInputController.text.trim().removeSuffix("/").trim(); if (tmpHost.isEmpty || !Uri.parse(tmpHost).isAbsolute) { setState(() { hostInvalidUrl = true; hostLoading = false; }); return; } http.Response request; try { request = await http .get( Uri.parse(tmpHost), headers: (jsonDecode(prefs!.getString("hostHeaders") ?? "{}") as Map) .cast(), ) .timeout(const Duration(seconds: 5), onTimeout: () { return http.Response("Error", 408); }); } catch (e) { setState(() { hostInvalidHost = true; hostLoading = false; }); return; } if ((request.statusCode == 200 && request.body == "Ollama is running") || (Uri.parse(tmpHost).toString() == fixedHost)) { setState(() { hostLoading = false; host = tmpHost; if (hostInputController.text != host!) { hostInputController.text = host!; } }); prefs?.setString("host", host!); } else { setState(() { hostInvalidHost = true; hostLoading = false; }); } HapticFeedback.selectionClick(); } final systemInputController = TextEditingController( text: prefs?.getString("system") ?? "You are a helpful assistant"); final repoUrl = "https://github.com/JHubi1/ollama-app"; bool updateChecked = false; bool updateLoading = false; String updateStatus = "ok"; String? updateUrl; String? latestVersion; String? currentVersion; Future updatesSupported([bool takeAction = false]) async { bool returnValue = true; var installerApps = [ "org.fdroid.fdroid", "org.gdroid.gdroid", "eu.bubu1.fdroidclassic", "in.sunilpaulmathew.izzyondroid", "com.looker.droidify", "com.machiav3lli.fdroid", "nya.kitsunyan.foxydroid" ]; if ((await InstallReferrer.referrer == InstallationAppReferrer.androidManually) && !(installerApps .contains((await InstallReferrer.app).packageName ?? ""))) { returnValue = false; } if (!repoUrl.startsWith("https://github.com")) { returnValue = false; } if (!returnValue && takeAction) { setState(() { updateStatus = "notAvailable"; updateLoading = false; }); } return returnValue; } void checkUpdate() async { setState(() { updateChecked = true; updateLoading = true; }); if (!await updatesSupported()) { setState(() { updateStatus = "notAvailable"; updateLoading = false; }); return; } var repo = repoUrl.split("/"); currentVersion = (await PackageInfo.fromPlatform()).version; // currentVersion = "1.0.0"; String? version; try { var request = await http .get(Uri.parse( "https://api.github.com/repos/${repo[3]}/${repo[4]}/tags")) .timeout(const Duration(seconds: 5)); if (request.statusCode == 403) { setState(() { updateStatus = "rateLimit"; updateLoading = false; }); return; } version = jsonDecode(request.body)[0]["name"]; } catch (_) { setState(() { updateStatus = "error"; updateLoading = false; }); return; } latestVersion = version; updateUrl = "$repoUrl/releases/tag/$latestVersion"; updateStatus = "ok"; setState(() { updateLoading = false; }); } @override void initState() { super.initState(); WidgetsFlutterBinding.ensureInitialized(); checkHost(); updatesSupported(true); if (prefs!.getBool("checkUpdateOnSettingsOpen") ?? false) { checkUpdate(); } } @override void dispose() { super.dispose(); hostInputController.dispose(); } Widget toggle(String text, bool value, Function(bool value) onChanged) { var space = "⁣"; // Invisible character: U+2063 var spacePlus = " $space"; return Stack(children: [ Padding( padding: const EdgeInsets.only(left: 16, right: 16, top: 12), child: Divider( color: (Theme.of(context).brightness == Brightness.light) ? Colors.grey[300] : Colors.grey[900])), Row(mainAxisSize: MainAxisSize.max, children: [ Expanded( child: Text(text + spacePlus, overflow: TextOverflow.ellipsis, maxLines: 1, style: TextStyle( backgroundColor: (Theme.of(context).brightness == Brightness.light) ? (theme ?? ThemeData()).colorScheme.surface : (themeDark ?? ThemeData.dark()) .colorScheme .surface))), Container( padding: const EdgeInsets.only(left: 16), color: (Theme.of(context).brightness == Brightness.light) ? (theme ?? ThemeData()).colorScheme.surface : (themeDark ?? ThemeData.dark()).colorScheme.surface, child: SizedBox( height: 40, child: Switch(value: value, onChanged: onChanged))) ]), ]); } Widget title(String text, {double top = 16, double bottom = 16}) { return Padding( padding: EdgeInsets.only(left: 8, right: 8, top: top, bottom: bottom), child: Row(children: [ const Expanded(child: Divider()), Padding( padding: const EdgeInsets.only(left: 24, right: 24), child: Text(text)), const Expanded(child: Divider()) ])); } @override Widget build(BuildContext context) { return PopScope( canPop: !hostLoading, onPopInvoked: (didPop) { settingsOpen = false; FocusManager.instance.primaryFocus?.unfocus(); }, child: WindowBorder( color: Theme.of(context).colorScheme.surface, child: Scaffold( appBar: AppBar( title: Row(children: [ Text(AppLocalizations.of(context)!.optionSettings), Expanded(child: SizedBox(height: 200, child: MoveWindow())) ]), actions: (Platform.isWindows || Platform.isLinux || Platform.isMacOS) ? [ SizedBox( height: 200, child: WindowTitleBarBox( child: Row( children: [ SizedBox( height: 200, child: MinimizeWindowButton( animate: true, colors: WindowButtonColors( iconNormal: Theme.of(context) .colorScheme .primary))), SizedBox( height: 72, child: MaximizeWindowButton( animate: true, colors: WindowButtonColors( iconNormal: Theme.of(context) .colorScheme .primary))), SizedBox( height: 72, child: CloseWindowButton( animate: true, colors: WindowButtonColors( iconNormal: Theme.of(context) .colorScheme .primary))), ], ))) ] : null, ), body: Padding( padding: const EdgeInsets.only(left: 16, right: 16), child: ListView(children: [ const SizedBox(height: 16), const SizedBox(height: 8), TextField( controller: hostInputController, keyboardType: TextInputType.url, readOnly: useHost, decoration: InputDecoration( labelText: AppLocalizations.of(context)!.settingsHost, hintText: "http://localhost:11434", prefixIcon: IconButton( onPressed: () async { HapticFeedback.selectionClick(); String tmp = await prompt(context, placeholder: "{\"Authorization\": \"Bearer ...\"}", title: AppLocalizations.of(context)! .settingsHostHeaderTitle, value: (prefs!.getString("hostHeaders") ?? ""), valueIfCanceled: "{}", validator: (content) async { try { var tmp = jsonDecode(content); tmp as Map; return true; } catch (_) { return false; } }, validatorError: AppLocalizations.of(context)! .settingsHostHeaderInvalid); prefs!.setString("hostHeaders", tmp); }, icon: const Icon(Icons.add_rounded)), suffixIcon: useHost ? const SizedBox.shrink() : (hostLoading ? Transform.scale( scale: 0.5, child: const CircularProgressIndicator()) : IconButton( onPressed: () { HapticFeedback.selectionClick(); checkHost(); }, icon: const Icon(Icons.save_rounded), )), border: const OutlineInputBorder(), error: (hostInvalidHost || hostInvalidUrl) ? InkWell( onTap: () { HapticFeedback.selectionClick(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of( context)! .settingsHostInvalidDetailed( hostInvalidHost ? "host" : "url")), showCloseIcon: true)); }, highlightColor: Colors.transparent, splashFactory: NoSplash.splashFactory, child: Row( children: [ const Icon(Icons.error_rounded, color: Colors.red), const SizedBox(width: 8), Text( AppLocalizations.of(context)! .settingsHostInvalid( hostInvalidHost ? "host" : "url"), style: const TextStyle( color: Colors.red)) ], )) : null, helper: InkWell( onTap: () { HapticFeedback.selectionClick(); }, highlightColor: Colors.transparent, splashFactory: NoSplash.splashFactory, child: hostLoading ? Row( children: [ const Icon(Icons.search_rounded, color: Colors.grey), const SizedBox(width: 8), Text( AppLocalizations.of(context)! .settingsHostChecking, style: const TextStyle( color: Colors.grey, fontFamily: "monospace")) ], ) : Row( children: [ const Icon(Icons.check_rounded, color: Colors.green), const SizedBox(width: 8), Text( AppLocalizations.of(context)! .settingsHostValid, style: const TextStyle( color: Colors.green, fontFamily: "monospace")) ], )))), title(AppLocalizations.of(context)!.settingsTitleBehavior, bottom: 24), TextField( controller: systemInputController, keyboardType: TextInputType.multiline, maxLines: 2, decoration: InputDecoration( labelText: AppLocalizations.of(context)! .settingsSystemMessage, hintText: "You are a helpful assistant", suffixIcon: IconButton( onPressed: () { HapticFeedback.selectionClick(); prefs?.setString( "system", (systemInputController.text.isNotEmpty) ? systemInputController.text : "You are a helpful assistant"); }, icon: const Icon(Icons.save_rounded), ), border: const OutlineInputBorder())), const SizedBox(height: 16), toggle(AppLocalizations.of(context)!.settingsDisableMarkdown, (prefs!.getBool("noMarkdown") ?? false), (value) { HapticFeedback.selectionClick(); prefs!.setBool("noMarkdown", value); setState(() {}); }), const SizedBox(height: 8), Row(children: [ const Icon(Icons.warning_rounded, color: Colors.grey), const SizedBox(width: 16), Expanded( child: Text( AppLocalizations.of(context)! .settingsBehaviorNotUpdatedForOlderChats, style: const TextStyle(color: Colors.grey))) ]), title(AppLocalizations.of(context)!.settingsTitleInterface), SegmentedButton( segments: const [ ButtonSegment( value: "stream", label: Text("Stream"), icon: Icon(Icons.stream_rounded)), ButtonSegment( value: "request", label: Text("Request"), icon: Icon(Icons.send_rounded)) ], selected: { prefs!.getString("requestType") ?? "stream" }, onSelectionChanged: (p0) { HapticFeedback.selectionClick(); setState(() { prefs!.setString("requestType", p0.elementAt(0)); }); }), const SizedBox(height: 16), toggle(AppLocalizations.of(context)!.settingsGenerateTitles, (prefs!.getBool("generateTitles") ?? true), (value) { HapticFeedback.selectionClick(); prefs!.setBool("generateTitles", value); setState(() {}); }), toggle(AppLocalizations.of(context)!.settingsAskBeforeDelete, (prefs!.getBool("askBeforeDeletion") ?? false), (value) { HapticFeedback.selectionClick(); prefs!.setBool("askBeforeDeletion", value); setState(() {}); }), toggle( AppLocalizations.of(context)!.settingsResetOnModelChange, (prefs!.getBool("resetOnModelSelect") ?? true), (value) { HapticFeedback.selectionClick(); prefs!.setBool("resetOnModelSelect", value); setState(() {}); }), toggle(AppLocalizations.of(context)!.settingsEnableEditing, (prefs!.getBool("enableEditing") ?? false), (value) { HapticFeedback.selectionClick(); prefs!.setBool("enableEditing", value); setState(() {}); }), toggle(AppLocalizations.of(context)!.settingsShowTips, (prefs!.getBool("tips") ?? true), (value) { HapticFeedback.selectionClick(); prefs!.setBool("tips", value); setState(() {}); }), toggle(AppLocalizations.of(context)!.settingsShowModelTags, (prefs!.getBool("modelTags") ?? false), (value) { HapticFeedback.selectionClick(); prefs!.setBool("modelTags", value); setState(() {}); }), const SizedBox(height: 16), SegmentedButton( segments: [ ButtonSegment( value: "dark", label: Text(AppLocalizations.of(context)! .settingsBrightnessDark), icon: const Icon(Icons.brightness_4_rounded)), ButtonSegment( value: "system", label: Text(AppLocalizations.of(context)! .settingsBrightnessSystem), icon: const Icon(Icons.brightness_auto_rounded)), ButtonSegment( value: "light", label: Text(AppLocalizations.of(context)! .settingsBrightnessLight), icon: const Icon(Icons.brightness_high_rounded)) ], selected: { prefs!.getString("brightness") ?? "system" }, onSelectionChanged: (p0) { HapticFeedback.selectionClick(); var tmp = prefs!.getString("brightness") ?? "system"; prefs!.setString("brightness", p0.elementAt(0)); setState(() {}); showDialog( context: context, builder: (context) { return StatefulBuilder( builder: (context, setLocalState) { return PopScope( onPopInvoked: (didPop) { prefs!.setString("brightness", tmp); setState(() {}); }, child: AlertDialog( title: Text(AppLocalizations.of( context)! .settingsBrightnessRestartTitle), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text(AppLocalizations.of(context)! .settingsBrightnessRestartDescription), ]), actions: [ TextButton( onPressed: () { HapticFeedback.selectionClick(); Navigator.of(context).pop(); }, child: Text(AppLocalizations.of( context)! .settingsBrightnessRestartCancel)), TextButton( onPressed: () async { HapticFeedback.selectionClick(); await prefs!.setString( "brightness", p0.elementAt(0)); if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { exit(0); } else { Restart.restartApp(); } }, child: Text(AppLocalizations.of( context)! .settingsBrightnessRestartRestart)) ])); }); }); }), title(AppLocalizations.of(context)!.settingsTitleExport), InkWell( onTap: () async { var path = await FilePicker.platform.saveFile( type: FileType.custom, allowedExtensions: ["json"], fileName: "ollama-export-${DateFormat('yyyy-MM-dd-H-m-s').format(DateTime.now())}.json", bytes: utf8.encode(jsonEncode( prefs!.getStringList("chats") ?? []))); if (path == null) return; if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) { File(path).writeAsString( jsonEncode(prefs!.getStringList("chats") ?? [])); } }, child: Row(children: [ const Icon(Icons.upload_rounded), const SizedBox(width: 16, height: 42), Expanded( child: Text(AppLocalizations.of(context)! .settingsExportChats)) ])), InkWell( onTap: () { showDialog( context: context, builder: (context) { return AlertDialog( title: Text(AppLocalizations.of(context)! .settingsImportChatsTitle), content: Text(AppLocalizations.of(context)! .settingsImportChatsDescription), actions: [ TextButton( onPressed: () { Navigator.of(context).pop(); }, child: Text( AppLocalizations.of(context)! .settingsImportChatsCancel)), TextButton( onPressed: () async { FilePickerResult? result = await FilePicker.platform .pickFiles( type: FileType.custom, allowedExtensions: [ "json" ]); if (result == null) { // ignore: use_build_context_synchronously Navigator.of(context).pop(); return; } File file = File(result.files.single.path!); var content = await file.readAsString(); List tmpHistory = jsonDecode(content); List history = []; for (var i = 0; i < tmpHistory.length; i++) { history.add(tmpHistory[i]); } prefs! .setStringList("chats", history); messages = []; chatUuid = null; setState(() {}); // ignore: use_build_context_synchronously Navigator.of(context).pop(); // ignore: use_build_context_synchronously Navigator.of(context).pop(); // ignore: use_build_context_synchronously ScaffoldMessenger.of(context) .showSnackBar(SnackBar( content: Text(AppLocalizations // ignore: use_build_context_synchronously .of(context)! .settingsImportChatsSuccess), showCloseIcon: true)); }, child: Text( AppLocalizations.of(context)! .settingsImportChatsImport)) ]); }); }, child: Row(children: [ const Icon(Icons.download_rounded), const SizedBox(width: 16, height: 42), Expanded( child: Text(AppLocalizations.of(context)! .settingsImportChats)) ])), title(AppLocalizations.of(context)!.settingsTitleContact), (updateStatus == "notAvailable") ? const SizedBox.shrink() : InkWell( onTap: () { if (updateLoading) return; if ((Version.parse(latestVersion ?? "1.0.0") > Version.parse(currentVersion ?? "2.0.0")) && (updateStatus == "ok")) { launchUrl( mode: LaunchMode.inAppBrowserView, Uri.parse(updateUrl!)); } else { checkUpdate(); return; } }, child: Row(children: [ updateLoading ? SizedBox( width: 24, height: 24, child: Transform.scale( scale: 0.5, child: const CircularProgressIndicator()), ) : Icon((updateStatus != "ok") ? Icons.warning_rounded : (Version.parse(latestVersion ?? "1.0.0") > Version.parse( currentVersion ?? "2.0.0")) ? Icons.info_outline_rounded : Icons.update_rounded), const SizedBox(width: 16, height: 42), Expanded( child: Text(!updateChecked ? AppLocalizations.of(context)! .settingsUpdateCheck : updateLoading ? AppLocalizations.of(context)! .settingsUpdateChecking : (updateStatus == "rateLimit") ? AppLocalizations.of(context)! .settingsUpdateRateLimit : (updateStatus != "ok") ? AppLocalizations.of(context)! .settingsUpdateIssue : (Version.parse( latestVersion ?? "1.0.0") > Version.parse( currentVersion ?? "2.0.0")) ? AppLocalizations.of( context)! .settingsUpdateAvailable( latestVersion!) : AppLocalizations.of( context)! .settingsUpdateLatest)) ])), (updateStatus == "notAvailable") ? const SizedBox.shrink() : toggle( AppLocalizations.of(context)!.settingsCheckForUpdates, (prefs!.getBool("checkUpdateOnSettingsOpen") ?? false), (value) { HapticFeedback.selectionClick(); prefs!.setBool("checkUpdateOnSettingsOpen", value); setState(() {}); }), InkWell( onTap: () { launchUrl( mode: LaunchMode.inAppBrowserView, Uri.parse(repoUrl)); }, child: Row(children: [ const Icon(SimpleIcons.github), const SizedBox(width: 16, height: 42), Expanded( child: Text( AppLocalizations.of(context)!.settingsGithub)) ])), InkWell( onTap: () { launchUrl( mode: LaunchMode.inAppBrowserView, Uri.parse("$repoUrl/issues")); }, child: Row(children: [ const Icon(Icons.report_rounded), const SizedBox(width: 16, height: 42), Expanded( child: Text(AppLocalizations.of(context)! .settingsReportIssue)) ])), InkWell( onTap: () { launchUrl( mode: LaunchMode.inAppBrowserView, Uri.parse(repoUrl.substring( 0, repoUrl.lastIndexOf('/')))); }, child: Row(children: [ const Icon(Icons.developer_board_rounded), const SizedBox(width: 16, height: 42), Expanded( child: Text(AppLocalizations.of(context)! .settingsMainDeveloper)) ])), const SizedBox(height: 16), ]))), ), ); } }