662 lines
32 KiB
Dart
662 lines
32 KiB
Dart
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';
|
||
|
||
class ScreenSettings extends StatefulWidget {
|
||
const ScreenSettings({super.key});
|
||
|
||
@override
|
||
State<ScreenSettings> createState() => _ScreenSettingsState();
|
||
}
|
||
|
||
class _ScreenSettingsState extends State<ScreenSettings> {
|
||
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<String, String>(),
|
||
)
|
||
.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)) {
|
||
// messages = [];
|
||
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";
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
checkHost();
|
||
}
|
||
|
||
@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) {
|
||
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: [
|
||
// Expanded(child: MoveWindow()),
|
||
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,
|
||
leading: (Navigator.of(context).canPop())
|
||
? null
|
||
: IconButton(
|
||
icon: const Icon(Icons.arrow_back),
|
||
onPressed: () {
|
||
if (hostLoading) return;
|
||
Navigator.of(context).pushReplacement(
|
||
// PageRouteBuilder(
|
||
// pageBuilder: (context, animation,
|
||
// secondaryAnimation) =>
|
||
// const MainApp(),
|
||
// // transitionDuration: const Duration(seconds: 1),
|
||
// transitionsBuilder: (context, animation,
|
||
// secondaryAnimation, child) {
|
||
// return FadeTransition(
|
||
// opacity: animation, child: child);
|
||
// })
|
||
MaterialPageRoute(
|
||
builder: (context) => const MainApp()));
|
||
})),
|
||
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<String, dynamic>;
|
||
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(() {});
|
||
}),
|
||
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<dynamic> tmpHistory =
|
||
jsonDecode(content);
|
||
List<String> 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),
|
||
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),
|
||
]))),
|
||
),
|
||
);
|
||
}
|
||
}
|