ollama-app/lib/screen_settings.dart

785 lines
37 KiB
Dart

import 'dart:convert';
import 'dart:async';
import 'package:flutter/material.dart';
import 'main.dart';
import 'worker/haptic.dart';
import 'worker/update.dart';
import 'worker/desktop.dart';
import 'worker/setter.dart';
import 'package:ollama_app/l10n/gen/app_localizations.dart';
import 'settings/behavior.dart';
import 'settings/interface.dart';
import 'settings/voice.dart';
import 'settings/export.dart';
import 'settings/about.dart';
import 'package:dartx/dartx.dart';
import 'package:http/http.dart' as http;
import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:transparent_image/transparent_image.dart';
import 'package:version/version.dart';
Widget toggle(BuildContext context, String text, bool value,
Function(bool value) onChanged,
{bool disabled = false,
void Function()? onDisabledTap,
void Function()? onLongTap,
void Function()? onDoubleTap,
Widget? icon,
bool? iconAfterwards}) {
return InkWell(
enableFeedback: false,
splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent,
hoverColor: Colors.transparent,
onTap: () {
if (disabled) {
selectionHaptic();
if (onDisabledTap != null) {
onDisabledTap();
}
} else {
onChanged(!value);
}
},
onLongPress: onLongTap,
onDoubleTap: onDoubleTap,
child: Padding(
padding: const EdgeInsets.only(top: 4, bottom: 4),
child: Row(children: [
Expanded(
child: LayoutBuilder(builder: (context, constraints) {
return Row(mainAxisSize: MainAxisSize.max, children: [
(icon != null && !(iconAfterwards ?? false))
? Padding(
padding: const EdgeInsets.only(right: 8),
child: icon,
)
: const SizedBox.shrink(),
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth - (icon != null ? 32 : 0)),
child: Text(text,
overflow: TextOverflow.ellipsis,
maxLines: 1,
style: TextStyle(color: disabled ? Colors.grey : null)),
),
(icon != null && (iconAfterwards ?? false))
? Transform.translate(
offset: const Offset(0, 1),
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: icon,
))
: const SizedBox.shrink(),
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 16),
child: Divider(color: Theme.of(context).dividerColor)),
),
]);
}),
),
Padding(
padding: const EdgeInsets.only(left: 16),
child: Switch(
value: value,
onChanged: disabled
? (onDisabledTap != null)
? (p0) {
selectionHaptic();
onDisabledTap();
}
: null
: onChanged,
activeTrackColor: disabled
? Theme.of(context).colorScheme.primary.withAlpha(50)
: null,
trackOutlineColor: disabled
? WidgetStatePropertyAll(
Theme.of(context).colorScheme.primary.withAlpha(150))
: null,
thumbColor: disabled
? WidgetStatePropertyAll(
Theme.of(context).colorScheme.primary.withAlpha(150))
: !(prefs?.getBool("useDeviceTheme") ?? false) && value
? WidgetStatePropertyAll(
Theme.of(context).colorScheme.secondary)
: null))
]),
),
);
}
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())
]));
}
Widget titleDivider({double? top, double? bottom, BuildContext? context}) {
top ??= (context != null && desktopLayoutNotRequired(context)) ? 32 : 16;
bottom ??= (context != null && desktopLayoutNotRequired(context)) ? 32 : 16;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.only(left: 8, right: 8, top: top, bottom: bottom),
child: const Row(
mainAxisSize: MainAxisSize.max,
children: [Expanded(child: Divider())]));
}
Widget verticalTitleDivider(
{double? left, double? right, BuildContext? context}) {
left ??= (context != null && desktopLayoutNotRequired(context)) ? 32 : 16;
right ??= (context != null && desktopLayoutNotRequired(context)) ? 32 : 16;
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.only(left: left, right: right, top: 8, bottom: 8),
child: const Row(
mainAxisSize: MainAxisSize.max,
children: [VerticalDivider(width: 1)]));
}
Widget button(String text, IconData? icon, void Function()? onPressed,
{BuildContext? context,
Color? color,
bool disabled = false,
bool replaceIconIfNull = false,
String? description,
bool onlyDesktopDescription = true,
bool alwaysMobileDescription = false,
String? badge,
String? iconBadge,
bool? iconAfterwards,
void Function()? onDisabledTap,
void Function()? onLongTap,
void Function()? onDoubleTap}) {
if (description != null &&
((context != null && desktopLayoutNotRequired(context)) ||
!onlyDesktopDescription) &&
!alwaysMobileDescription &&
!description.startsWith("\n")) {
description = "$description";
}
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: (context != null && desktopLayoutNotRequired(context))
? const EdgeInsets.only(top: 8, bottom: 8)
: EdgeInsets.zero,
child: InkWell(
enableFeedback: false,
// disable hint that clickable, other tap functions still functional
splashFactory: (onPressed == null) ? NoSplash.splashFactory : null,
highlightColor: (onPressed == null) ? Colors.transparent : null,
hoverColor: (onPressed == null) ? Colors.transparent : null,
onTap: disabled
? () {
selectionHaptic();
if (onDisabledTap != null) {
onDisabledTap();
}
}
: (onPressed == null && (onLongTap != null || onDoubleTap != null))
? () {
selectionHaptic();
}
: onPressed,
onLongPress: (description != null && context != null)
? (desktopLayoutNotRequired(context) && !alwaysMobileDescription) ||
!onlyDesktopDescription
? null
: () {
selectionHaptic();
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(description!.trim()),
showCloseIcon: true));
}
: onLongTap,
onDoubleTap: onDoubleTap,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(8),
child: Builder(builder: (context) {
var iconContent = (icon != null || replaceIconIfNull)
? replaceIconIfNull
? ImageIcon(MemoryImage(kTransparentImage))
: Icon(icon,
color: disabled || (iconAfterwards ?? false)
? Colors.grey
: color)
: const SizedBox.shrink();
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
!(iconAfterwards ?? false)
? (iconBadge == null)
? iconContent
: Badge(
label: (iconBadge != "") ? Text(iconBadge) : null,
child: iconContent)
: const SizedBox.shrink(),
(icon != null || replaceIconIfNull)
? SizedBox(
width: !(iconAfterwards ?? false) ? 16 : null,
height: 42)
: const SizedBox.shrink(),
Expanded(child: Builder(builder: (context) {
Widget textWidget = Text(text,
style:
TextStyle(color: disabled ? Colors.grey : color));
if (badge != null) {
textWidget = Badge(
label: Text(badge),
offset: const Offset(20, -4),
backgroundColor:
Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
child: textWidget);
}
if (iconAfterwards ?? false) {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
textWidget,
// same info distance as [toggle]
const SizedBox(width: 8),
Transform.translate(
offset: const Offset(0, 1),
child: (iconBadge == null)
? iconContent
: Badge(
label: (iconBadge != "")
? Text(iconBadge)
: null,
child: iconContent)),
]);
} else {
if (description == null ||
description!.startsWith("\n")) {
description = description?.removePrefix("\n");
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
textWidget,
(description != null &&
!alwaysMobileDescription &&
(desktopLayoutNotRequired(context) ||
!onlyDesktopDescription))
? Text(description!,
style: const TextStyle(
color: Colors.grey,
overflow: TextOverflow.ellipsis))
: const SizedBox.shrink()
]);
} else {
return Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
textWidget,
(description != null &&
!alwaysMobileDescription &&
(desktopLayoutNotRequired(context) ||
!onlyDesktopDescription))
? Expanded(
child: Text(description!,
style: const TextStyle(
color: Colors.grey,
overflow: TextOverflow.ellipsis)),
)
: const SizedBox.shrink()
]);
}
}
}))
]);
}),
)),
);
}
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 {
// don't use centralized client because of unexplainable inconsistency
// between the ways of calling a request
final requestBase = http.Request("get", Uri.parse(tmpHost))
..headers.addAll(
(jsonDecode(prefs!.getString("hostHeaders") ?? "{}") as Map)
.cast<String, String>(),
)
..followRedirects = false;
request = await http.Response.fromStream(await requestBase.send().timeout(
Duration(
milliseconds:
(5000.0 * (prefs!.getDouble("timeoutMultiplier") ?? 1.0))
.round()), onTimeout: () {
return http.StreamedResponse(const Stream.empty(), 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! &&
(Uri.parse(tmpHost).toString() != fixedHost)) {
hostInputController.text = host!;
}
});
prefs?.setString("host", host!);
} else {
setState(() {
hostInvalidHost = true;
hostLoading = false;
});
}
selectionHaptic();
}
double iconSize = 1;
bool animatedInitialized = false;
bool animatedDesktop = false;
@override
void initState() {
super.initState();
WidgetsFlutterBinding.ensureInitialized();
if ((Uri.parse(hostInputController.text.trim().removeSuffix("/").trim())
.toString() !=
fixedHost)) {
checkHost();
}
}
@override
void dispose() {
super.dispose();
hostInputController.dispose();
}
@override
Widget build(BuildContext context) {
if (!animatedInitialized) {
animatedInitialized = true;
animatedDesktop = desktopLayoutNotRequired(context);
}
return PopScope(
canPop: !hostLoading,
onPopInvokedWithResult: (didPop, result) {
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: desktopControlsActions(context)),
body: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 1200),
padding: const EdgeInsets.only(left: 16, right: 16),
child: LayoutBuilder(builder: (context, constraints) {
var column1 =
Column(mainAxisSize: MainAxisSize.min, children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: animatedDesktop ? 8 : 0,
child: const SizedBox.shrink()),
const SizedBox(height: 8),
TextField(
controller: hostInputController,
keyboardType: TextInputType.url,
autofillHints: const [AutofillHints.url],
readOnly: useHost,
onSubmitted: (value) {
selectionHaptic();
checkHost();
},
decoration: InputDecoration(
labelText: AppLocalizations.of(context)!
.settingsHost,
hintText: "http://localhost:11434",
prefixIcon: IconButton(
enableFeedback: false,
tooltip: AppLocalizations.of(context)!
.tooltipAddHostHeaders,
onPressed: () async {
selectionHaptic();
String tmp = await prompt(context,
placeholder:
"{\"Authorization\": \"Bearer ...\"}",
title: AppLocalizations.of(context)!
.settingsHostHeaderTitle,
value: (prefs!
.getString("hostHeaders") ??
""),
enableSuggestions: false,
valueIfCanceled: "{}",
validator: (content) async {
try {
var tmp = jsonDecode(content);
tmp as Map<String, dynamic>;
return true;
} catch (_) {
return false;
}
},
validatorError:
AppLocalizations.of(context)!
.settingsHostHeaderInvalid,
prefill: !((prefs!.getString(
"hostHeaders") ??
{}) ==
"{}"));
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(
enableFeedback: false,
tooltip:
AppLocalizations.of(context)!
.tooltipSave,
onPressed: () {
selectionHaptic();
checkHost();
},
icon: const Icon(
Icons.save_rounded),
)),
border: const OutlineInputBorder(),
error: (hostInvalidHost || hostInvalidUrl)
? InkWell(
onTap: () {
selectionHaptic();
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(
content: Text(AppLocalizations
.of(context)!
.settingsHostInvalidDetailed(
hostInvalidHost
? "host"
: "url")),
showCloseIcon: true));
},
splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent,
hoverColor: Colors.transparent,
child: Row(
children: [
Icon(Icons.error_rounded,
color: Theme.of(context)
.colorScheme
.error),
const SizedBox(width: 8),
Text(
AppLocalizations.of(context)!
.settingsHostInvalid(
hostInvalidHost
? "host"
: "url"),
style: TextStyle(
color: Theme.of(context)
.colorScheme
.error))
],
))
: null,
helper: InkWell(
onTap: () {
selectionHaptic();
},
splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent,
hoverColor: Colors.transparent,
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: [
Icon(Icons.check_rounded,
color: Colors.green
.harmonizeWith(
Theme.of(context)
.colorScheme
.primary)),
const SizedBox(width: 8),
Text(
AppLocalizations.of(
context)!
.settingsHostValid,
style: TextStyle(
color: Colors.green
.harmonizeWith(
Theme.of(
context)
.colorScheme
.primary),
fontFamily:
"monospace"))
],
))))
]);
var column2 =
Column(mainAxisSize: MainAxisSize.min, children: [
button(
AppLocalizations.of(context)!
.settingsTitleBehavior,
Icons.psychology_rounded, () {
selectionHaptic();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const ScreenSettingsBehavior()));
},
context: context,
description:
"\n${AppLocalizations.of(context)!.settingsDescriptionBehavior}"),
button(
AppLocalizations.of(context)!
.settingsTitleInterface,
Icons.web_asset_rounded, () {
selectionHaptic();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const ScreenSettingsInterface()));
},
context: context,
description:
"\n${AppLocalizations.of(context)!.settingsDescriptionInterface}"),
(!desktopFeature(web: true))
? button(
AppLocalizations.of(context)!
.settingsTitleVoice,
Icons.headphones_rounded, () {
selectionHaptic();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const ScreenSettingsVoice()));
},
context: context,
description:
"\n${AppLocalizations.of(context)!.settingsDescriptionVoice}",
badge: AppLocalizations.of(context)!
.settingsExperimentalBeta)
: const SizedBox.shrink(),
button(
AppLocalizations.of(context)!.settingsTitleExport,
Icons.share_rounded, () {
selectionHaptic();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const ScreenSettingsExport()));
},
context: context,
description:
"\n${AppLocalizations.of(context)!.settingsDescriptionExport}"),
Builder(builder: (context) {
return button(
AppLocalizations.of(context)!
.settingsTitleAbout,
Icons.help_rounded, () {
selectionHaptic();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
const ScreenSettingsAbout()));
},
context: context,
description:
"\n${AppLocalizations.of(context)!.settingsDescriptionAbout}",
iconBadge: (updateStatus == "ok" &&
updateDetectedOnStart &&
(Version.parse(
latestVersion ?? "1.0.0") >
Version.parse(
currentVersion ?? "2.0.0")))
? ""
: null);
})
]);
animatedDesktop = desktopLayoutNotRequired(context);
return Column(children: [
Expanded(
child: desktopLayoutNotRequired(context)
? Row(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
mainAxisSize:
MainAxisSize.max,
children: [
column1,
Expanded(
child: Center(
child: InkWell(
splashFactory:
NoSplash.splashFactory,
highlightColor:
Colors.transparent,
enableFeedback: false,
hoverColor:
Colors.transparent,
onTap: () async {
if (iconSize != 1) return;
heavyHaptic();
setState(() {
iconSize = 0.8;
});
await Future.delayed(
const Duration(
milliseconds: 200));
setState(() {
iconSize = 1.2;
});
await Future.delayed(
const Duration(
milliseconds: 200));
setState(() {
iconSize = 1;
});
},
child: AnimatedScale(
scale: iconSize,
duration: const Duration(
milliseconds: 400),
child: const ImageIcon(
AssetImage(
"assets/logo512.png"),
size: 44),
),
))),
Transform.translate(
offset: const Offset(0, 8),
child: button(
AppLocalizations.of(
context)!
.settingsSavedAutomatically,
Icons.info_rounded,
null,
color: Colors.grey
.harmonizeWith(
Theme.of(context)
.colorScheme
.primary)),
)
])),
verticalTitleDivider(
context: context),
Expanded(child: column2)
])
: ListView(children: [
column1,
AnimatedOpacity(
opacity: animatedDesktop ? 0 : 1,
duration:
const Duration(milliseconds: 200),
child: titleDivider(bottom: 4)),
AnimatedOpacity(
opacity: animatedDesktop ? 0 : 1,
duration:
const Duration(milliseconds: 200),
child: column2)
])),
const SizedBox(height: 8),
desktopLayoutNotRequired(context)
? const SizedBox.shrink()
: button(
AppLocalizations.of(context)!
.settingsSavedAutomatically,
Icons.info_rounded,
null,
color: Colors.grey.harmonizeWith(
Theme.of(context).colorScheme.primary))
]);
})),
))));
}
}