From 80217688839a64ccecf00b00c463c1d4f90bec3f Mon Sep 17 00:00:00 2001 From: otsmr Date: Mon, 27 Apr 2026 20:20:12 +0200 Subject: [PATCH] improve faq to directly open a specific question --- lib/src/model/json/faq.model.dart | 89 ++++++++++++++++++ lib/src/model/json/faq.model.g.dart | 59 ++++++++++++ .../views/settings/help/contact_us.view.dart | 23 ++--- .../visual/views/settings/help/faq.view.dart | 94 ++++++++++++------- .../user_discovery_enabled.comp.dart | 7 +- 5 files changed, 222 insertions(+), 50 deletions(-) create mode 100644 lib/src/model/json/faq.model.dart create mode 100644 lib/src/model/json/faq.model.g.dart diff --git a/lib/src/model/json/faq.model.dart b/lib/src/model/json/faq.model.dart new file mode 100644 index 00000000..79bf4e62 --- /dev/null +++ b/lib/src/model/json/faq.model.dart @@ -0,0 +1,89 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'faq.model.g.dart'; + +@JsonSerializable() +class FaqData { + const FaqData({required this.languages}); + + factory FaqData.fromJson(Map json) { + return FaqData( + languages: json.map( + (key, value) => MapEntry( + key, + (value as Map).map( + (catKey, catValue) => MapEntry( + catKey, + FaqCategory.fromJson(catValue as Map), + ), + ), + ), + ), + ); + } + + final Map> languages; + + Map toJson() => languages.map( + (key, value) => MapEntry( + key, + value.map((catKey, catValue) => MapEntry(catKey, catValue.toJson())), + ), + ); +} + +@JsonSerializable() +class FaqCategory { + const FaqCategory({ + required this.meta, + required this.questions, + }); + + factory FaqCategory.fromJson(Map json) => + _$FaqCategoryFromJson(json); + + final FaqMeta meta; + final List questions; + + Map toJson() => _$FaqCategoryToJson(this); +} + +@JsonSerializable() +class FaqMeta { + const FaqMeta({ + required this.title, + required this.desc, + this.priority = 0, + }); + + factory FaqMeta.fromJson(Map json) => + _$FaqMetaFromJson(json); + + final String title; + final String desc; + + @JsonKey(defaultValue: 0) + final int priority; + + Map toJson() => _$FaqMetaToJson(this); +} + +@JsonSerializable() +class FaqQuestion { + const FaqQuestion({ + required this.id, + required this.title, + required this.body, + required this.path, + }); + + factory FaqQuestion.fromJson(Map json) => + _$FaqQuestionFromJson(json); + + final String id; + final String title; + final String body; + final String path; + + Map toJson() => _$FaqQuestionToJson(this); +} diff --git a/lib/src/model/json/faq.model.g.dart b/lib/src/model/json/faq.model.g.dart new file mode 100644 index 00000000..8432ff2b --- /dev/null +++ b/lib/src/model/json/faq.model.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'faq.model.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +FaqData _$FaqDataFromJson(Map json) => FaqData( + languages: (json['languages'] as Map).map( + (k, e) => MapEntry( + k, + (e as Map).map( + (k, e) => MapEntry(k, FaqCategory.fromJson(e as Map)), + ), + ), + ), +); + +Map _$FaqDataToJson(FaqData instance) => { + 'languages': instance.languages, +}; + +FaqCategory _$FaqCategoryFromJson(Map json) => FaqCategory( + meta: FaqMeta.fromJson(json['meta'] as Map), + questions: (json['questions'] as List) + .map((e) => FaqQuestion.fromJson(e as Map)) + .toList(), +); + +Map _$FaqCategoryToJson(FaqCategory instance) => + {'meta': instance.meta, 'questions': instance.questions}; + +FaqMeta _$FaqMetaFromJson(Map json) => FaqMeta( + title: json['title'] as String, + desc: json['desc'] as String, + priority: (json['priority'] as num?)?.toInt() ?? 0, +); + +Map _$FaqMetaToJson(FaqMeta instance) => { + 'title': instance.title, + 'desc': instance.desc, + 'priority': instance.priority, +}; + +FaqQuestion _$FaqQuestionFromJson(Map json) => FaqQuestion( + id: json['id'] as String, + title: json['title'] as String, + body: json['body'] as String, + path: json['path'] as String, +); + +Map _$FaqQuestionToJson(FaqQuestion instance) => + { + 'id': instance.id, + 'title': instance.title, + 'body': instance.body, + 'path': instance.path, + }; diff --git a/lib/src/visual/views/settings/help/contact_us.view.dart b/lib/src/visual/views/settings/help/contact_us.view.dart index 7bcbde94..26840839 100644 --- a/lib/src/visual/views/settings/help/contact_us.view.dart +++ b/lib/src/visual/views/settings/help/contact_us.view.dart @@ -4,16 +4,18 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:fixnum/fixnum.dart'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:go_router/go_router.dart'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; import 'package:twonly/locator.dart'; +import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/constants/secure_storage.keys.dart'; import 'package:twonly/src/model/protobuf/api/http/http_requests.pb.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/secure_storage.dart'; import 'package:twonly/src/visual/views/settings/help/contact_us/submit_message.view.dart'; -import 'package:url_launcher/url_launcher.dart'; +import 'package:twonly/src/visual/views/settings/help/faq.view.dart'; class ContactUsView extends StatefulWidget { const ContactUsView({super.key}); @@ -237,9 +239,7 @@ $debugLogToken mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ GestureDetector( - onTap: () async { - await launchUrl(Uri.parse('https://twonly.eu/en/faq/')); - }, + onTap: () => context.push(Routes.settingsHelpFaq), child: Text( context.lang.contactUsFaq, style: const TextStyle( @@ -296,15 +296,6 @@ class IncludeDebugLog extends StatefulWidget { } class _IncludeDebugLogState extends State { - Future _launchURL() async { - const url = 'https://twonly.eu/en/faq/troubleshooting/debug-log.html'; - if (await launchUrl(Uri.parse(url))) { - } else { - // ignore: only_throw_errors - throw 'Could not launch $url'; - } - } - @override Widget build(BuildContext context) { return Row( @@ -321,7 +312,11 @@ class _IncludeDebugLogState extends State { Text(context.lang.contactUsIncludeLog), const SizedBox(width: 20), GestureDetector( - onTap: _launchURL, + onTap: () => context.navPush( + const FaqView( + questionId: 'debug-log', + ), + ), child: Text( context.lang.contactUsWhatsThat, style: const TextStyle( diff --git a/lib/src/visual/views/settings/help/faq.view.dart b/lib/src/visual/views/settings/help/faq.view.dart index 0c358e49..83a56626 100644 --- a/lib/src/visual/views/settings/help/faq.view.dart +++ b/lib/src/visual/views/settings/help/faq.view.dart @@ -1,24 +1,26 @@ -// ignore_for_file: avoid_dynamic_calls, inference_failure_on_untyped_parameter - import 'dart:async'; import 'dart:convert'; import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:twonly/globals.dart'; +import 'package:twonly/src/model/json/faq.model.dart'; import 'package:twonly/src/utils/log.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/visual/views/settings/help/faq/faq_markdown.view.dart'; class FaqView extends StatefulWidget { - const FaqView({super.key}); + const FaqView({this.questionId, super.key}); + + final String? questionId; @override State createState() => _FaqViewState(); } class _FaqViewState extends State { - Map? _faqData; + Map? _faqData; late String domain; bool _noInternet = false; @@ -31,14 +33,16 @@ class _FaqViewState extends State { Future _fetchFAQData() async { final cacheFile = File('${AppEnvironment.cacheDir}/faq.json'); + FaqData? faqData; try { final response = await http.get(Uri.parse('$domain/faq.json')); if (response.statusCode == 200) { final jsonData = utf8.decode(response.bodyBytes); setState(() { - _faqData = json.decode(jsonData) as Map?; - + faqData = FaqData.fromJson( + json.decode(jsonData) as Map, + ); _noInternet = false; }); cacheFile.writeAsStringSync(jsonData); @@ -54,11 +58,45 @@ class _FaqViewState extends State { if (_noInternet && cacheFile.existsSync()) { final jsonData = cacheFile.readAsStringSync(); setState(() { - _faqData = json.decode(jsonData) as Map?; - + faqData = FaqData.fromJson( + json.decode(jsonData) as Map, + ); _noInternet = false; }); } + + if (!mounted) return; + + final locale = Localizations.localeOf(context).languageCode; + _faqData = faqData!.languages[locale] ?? faqData!.languages['en']; + + if (widget.questionId != null && _faqData != null) { + if (!_navigateToQuestion(widget.questionId!, _faqData!)) { + final englishData = faqData!.languages['en']; + if (englishData != null && englishData != _faqData) { + _navigateToQuestion(widget.questionId!, englishData); + } + } + } + } + + bool _navigateToQuestion(String questionId, Map data) { + for (final category in data.values) { + for (final question in category.questions) { + if (question.id == questionId) { + unawaited( + context.navPush( + FaqMarkdownView( + markdown: question.body, + title: question.title, + ), + ), + ); + return true; + } + } + } + return false; } @override @@ -83,17 +121,9 @@ class _FaqViewState extends State { ); } - final locale = Localizations.localeOf(context).languageCode; - var faq = _faqData!['en'] as Map; - if (_faqData!.containsKey(locale)) { - faq = _faqData![locale] as Map; - } - - final sortedCategories = faq.entries.toList() + final sortedCategories = _faqData!.entries.toList() ..sort((a, b) { - final aPriority = (a.value['meta']['priority'] as num? ?? 0).toInt(); - final bPriority = (b.value['meta']['priority'] as num? ?? 0).toInt(); - return bPriority.compareTo(aPriority); + return b.value.meta.priority.compareTo(a.value.meta.priority); }); return Scaffold( @@ -107,24 +137,22 @@ class _FaqViewState extends State { return Card( child: ExpansionTile( - title: Text(categoryData['meta']['title'] as String), - subtitle: Text(categoryData['meta']['desc'] as String), + title: Text(categoryData.meta.title), + subtitle: Text(categoryData.meta.desc), shape: const RoundedRectangleBorder(), backgroundColor: context.color.surfaceContainer, collapsedShape: const RoundedRectangleBorder(), - children: - categoryData['questions'].map((question) { - return ListTile( - title: Text(question['title'] as String), - onTap: () => context.navPush( - FaqMarkdownView( - markdown: question['body'] as String, - title: question['title'] as String, - ), - ), - ); - }).toList() - as List, + children: categoryData.questions.map((question) { + return ListTile( + title: Text(question.title), + onTap: () => context.navPush( + FaqMarkdownView( + markdown: question.body, + title: question.title, + ), + ), + ); + }).toList(), ), ); }, diff --git a/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_enabled.comp.dart b/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_enabled.comp.dart index bbf11460..8729cf6c 100644 --- a/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_enabled.comp.dart +++ b/lib/src/visual/views/settings/privacy/user_discovery/components/user_discovery_enabled.comp.dart @@ -2,9 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:twonly/locator.dart'; -import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/protobuf/client/generated/user_discovery/types.pb.dart'; @@ -14,6 +12,7 @@ import 'package:twonly/src/visual/components/alert.dialog.dart'; import 'package:twonly/src/visual/components/avatar_icon.comp.dart'; import 'package:twonly/src/visual/context_menu/user.context_menu.dart'; import 'package:twonly/src/visual/themes/light.dart'; +import 'package:twonly/src/visual/views/settings/help/faq.view.dart'; import 'package:twonly/src/visual/views/settings/privacy/user_discovery/user_discovery_settings.view.dart'; class UserDiscoveryEnabledComp extends StatefulWidget { @@ -158,7 +157,9 @@ class _UserDiscoveryEnabledCompState extends State { subtitle: Text( context.lang.userDiscoveryEnabledFaq, ), - onTap: () => context.push(Routes.settingsHelpFaq), + onTap: () => context.navPush( + const FaqView(questionId: 'user-discovery'), + ), ), const Divider(), ListTile(