diff --git a/README.md b/README.md index bf8c24f4..a12064e0 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,32 @@ # Andax -Expandable parallel reference to the languages of the Caucasus in English. Currently includes Dictionary, with more modules to come soon. Legacy experimental implementation with 4 modules is available at https://ex.avzag.app/. +Expandable parallel dictionary for the languages of the Caucasus in English. -Google Play: https://play.google.com/store/apps/details?id=com.alkaitagi.avzag -Web application: https://avzag.app/. +Google Play: +Web application: . Built with Flutter & Firebase & Algolia. ## Roadmap ### 2021 + - [x] September: start of beta-test. - [x] November: release of crowdsourcing functionality. - [x] December: general availability on Android & Web. ### 2022 + - [x] Spring: Home-screen map, iOS release. - [x] Summer: Deep-linking, word sharing. - [ ] Autumn: Word of the day, search UI improvements. - [ ] Winter: Material 3, UI & UX overhaul. ## Getting Started + ```sh -> git clone https://github.com/raxysstudios/avzag.git -> cd avzag +> git clone https://github.com/raxysstudios/bazur.git +> cd bazur > pub get > flutter run -d chrome --web-port 80 ``` diff --git a/android/app/src/debug/res/values/string.xml b/android/app/src/debug/res/values/string.xml index 6d209371..3d2aeeb5 100644 --- a/android/app/src/debug/res/values/string.xml +++ b/android/app/src/debug/res/values/string.xml @@ -1,4 +1,4 @@ - 🛠️ Avzag + 🛠️ Bazur diff --git a/android/app/src/main/res/values/string.xml b/android/app/src/main/res/values/string.xml index 41b1d27c..bd05eef4 100644 --- a/android/app/src/main/res/values/string.xml +++ b/android/app/src/main/res/values/string.xml @@ -1,4 +1,4 @@ - Avzag + Bazur diff --git a/functions/src/dictionary.ts b/functions/src/dictionary.ts index 6116137b..5682f0e0 100644 --- a/functions/src/dictionary.ts +++ b/functions/src/dictionary.ts @@ -2,6 +2,7 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import * as functions from "firebase-functions"; import algoliasearch from "algoliasearch"; +import {firestore} from "firebase-admin"; const dictionary = algoliasearch( functions.config().algolia.app, @@ -25,6 +26,8 @@ export const indexDictionary = functions entryID, headword: entry.headword, language: entry.language, + rand: Math.random(), + lastUpdated: firestore.Timestamp.now().toDate().valueOf(), forms: [ entry.headword, ...(entry.forms?.map((s: any) => s.text) ?? []), @@ -36,13 +39,15 @@ export const indexDictionary = functions const records = []; - for (const use of entry.uses) { - const record = Object.assign({term: use.term}, base) as any; - if (use.tags?.length || entry.tags?.length) { - record.tags = (use.tags ?? []).concat(entry.tags ?? []); + for (const definition of entry.definitions) { + const record = Object.assign({ + translation: definition.translation, + }, base) as any; + if (definition.tags?.length || entry.tags?.length) { + record.tags = (definition.tags ?? []).concat(entry.tags ?? []); } - if (use.aliases?.length) { - record.aliases = use.aliases; + if (definition.aliases?.length) { + record.aliases = definition.aliases; } records.push(record); } diff --git a/functions/src/stats.ts b/functions/src/stats.ts index d06f62a7..44a16d4c 100644 --- a/functions/src/stats.ts +++ b/functions/src/stats.ts @@ -9,23 +9,23 @@ const dictionary = algoliasearch( export const collectStats = functions .region("europe-central2") - .pubsub.schedule("every 6 hours") + .pubsub.schedule("every 24 hours") .timeZone("Europe/Moscow") .onRun(async () => { const db = admin.firestore(); const langs = await db - .collection("languages").get() + .collection("languages") + .get() .then((d) => d.docs.map((l) => l.id)); for (const lang of langs) { await db.doc("languages/" + lang).update({ - stats: { - dictionary: await dictionary - .search("", { - facetFilters: ["language:" + lang], - hitsPerPage: 0, - }).then((s) => s.nbHits), - }, + dictionary: await dictionary + .search("", { + facetFilters: ["language:" + lang], + hitsPerPage: 0, + }) + .then((s) => s.nbHits), }); } }); diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index ba7337c1..ceec8823 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -11,7 +11,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - avzag + bazur CFBundlePackageType APPL CFBundleShortVersionString @@ -60,7 +60,7 @@ CFBundleTypeRole Editor CFBundleURLName - avzag.raxys.app + bazur.raxys.app CFBundleURLSchemes customscheme diff --git a/lib/main.dart b/lib/main.dart index 66c39db5..2e1ab023 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,5 @@ import 'package:algolia/algolia.dart'; -import 'package:avzag/store.dart'; +import 'package:bazur/store.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/material.dart'; @@ -8,8 +8,8 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_strategy/url_strategy.dart'; import 'firebase_options.dart'; -import 'modules/navigation/services/root_guard.dart'; -import 'modules/navigation/services/router.gr.dart'; +import 'navigation/root_guard.dart'; +import 'navigation/router.gr.dart'; import 'theme_set.dart'; void main() async { @@ -41,7 +41,7 @@ class App extends StatelessWidget { Widget build(context) { final theme = ThemeSet(Theme.of(context).colorScheme); return MaterialApp.router( - title: 'Avzag', + title: 'Bazur', theme: theme.light, darkTheme: theme.dark, routerDelegate: _appRouter.delegate(), diff --git a/lib/modules/dictionary/models/entry.dart b/lib/models/entry.dart similarity index 87% rename from lib/modules/dictionary/models/entry.dart rename to lib/models/entry.dart index 38672c4d..78bcd63f 100644 --- a/lib/modules/dictionary/models/entry.dart +++ b/lib/models/entry.dart @@ -1,5 +1,5 @@ import 'package:algolia/algolia.dart'; -import 'package:avzag/shared/utils.dart'; +import 'package:bazur/shared/utils.dart'; class Entry { final String entryID; @@ -7,7 +7,7 @@ class Entry { final String headword; final String? form; final String language; - final String term; + final String translation; final bool unverified; final List? tags; @@ -17,7 +17,7 @@ class Entry { required this.headword, this.form, required this.language, - required this.term, + required this.translation, this.tags, this.unverified = false, }); @@ -37,7 +37,7 @@ class Entry { headword: json['headword'] as String, form: form >= 0 ? json2list(json['forms'])![form] : null, language: json['language'] as String, - term: json['term'] as String, + translation: json['translation'] as String, unverified: json['unverified'] as bool? ?? false, tags: json2list(json['tags']), ); diff --git a/lib/models/language.dart b/lib/models/language.dart index bfc9b7cb..aad1fe3f 100644 --- a/lib/models/language.dart +++ b/lib/models/language.dart @@ -1,53 +1,30 @@ -import 'package:avzag/shared/utils.dart'; +import 'package:bazur/shared/utils.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; -class LanguageStats { - final int dictionary; - - LanguageStats({ - this.dictionary = 0, - }); - - LanguageStats.fromJson(Map json) - : this( - dictionary: json['dictionary'] as int, - ); - - Map toJson() => { - 'dictionary': dictionary, - }; -} - class Language { final String name; final String endonym; - final String? flag; final String? contact; final List? aliases; final GeoPoint? location; - final LanguageStats? stats; + final int dictionary; const Language({ required this.name, required this.endonym, - this.flag, this.contact, this.aliases, this.location, - this.stats, + this.dictionary = 0, }); Language.fromJson(Map json) : this( name: json['name'] as String, endonym: json['endonym'] as String, - flag: json['flag'] as String?, contact: json['contact'] as String?, aliases: json2list(json['aliases']), - location: - json['location'] == null ? null : json['location'] as GeoPoint, - stats: json['stats'] == null - ? null - : LanguageStats.fromJson(json['stats'] as Map), + location: json['location'] as GeoPoint?, + dictionary: json['dictionary'] as int? ?? 0, ); } diff --git a/lib/modules/dictionary/models/sample.dart b/lib/models/sample.dart similarity index 100% rename from lib/modules/dictionary/models/sample.dart rename to lib/models/sample.dart diff --git a/lib/modules/dictionary/models/use.dart b/lib/models/use.dart similarity index 78% rename from lib/modules/dictionary/models/use.dart rename to lib/models/use.dart index 3d755b97..c0db2f5e 100644 --- a/lib/modules/dictionary/models/use.dart +++ b/lib/models/use.dart @@ -1,25 +1,25 @@ -import 'package:avzag/shared/utils.dart'; +import 'package:bazur/shared/utils.dart'; import 'sample.dart'; -class Use { - String term; +class Definition { + String translation; List aliases; List tags; String? note; List examples; - Use( - this.term, { + Definition( + this.translation, { required this.aliases, required this.tags, this.note, required this.examples, }); - Use.fromJson(Map json) + Definition.fromJson(Map json) : this( - json['term'] as String, + json['translation'] as String, aliases: json2list(json['aliases']) ?? [], tags: json2list(json['tags']) ?? [], note: json['note'] as String?, @@ -32,7 +32,7 @@ class Use { Map toJson() { final data = {}; - data['term'] = term; + data['translation'] = translation; if (aliases.isNotEmpty) data['aliases'] = aliases; if (tags.isNotEmpty) data['tags'] = tags; if (note?.isNotEmpty ?? false) data['note'] = note; diff --git a/lib/modules/dictionary/models/word.dart b/lib/models/word.dart similarity index 81% rename from lib/modules/dictionary/models/word.dart rename to lib/models/word.dart index d9362f93..cf138770 100644 --- a/lib/modules/dictionary/models/word.dart +++ b/lib/models/word.dart @@ -1,5 +1,5 @@ -import 'package:avzag/models/contribution.dart'; -import 'package:avzag/shared/utils.dart'; +import 'package:bazur/models/contribution.dart'; +import 'package:bazur/shared/utils.dart'; import 'sample.dart'; import 'use.dart'; @@ -12,7 +12,7 @@ class Word { String language; List tags; String? note; - List uses; + List definitions; Contribution? contribution; Word( @@ -20,7 +20,7 @@ class Word { required this.headword, this.ipa, required this.language, - required this.uses, + required this.definitions, required this.forms, required this.tags, this.contribution, @@ -38,9 +38,9 @@ class Word { ) ?? [], language: json['language'] as String, - uses: listFromJson( - json['uses'], - (dynamic j) => Use.fromJson(j as Map), + definitions: listFromJson( + json['definitions'], + (dynamic j) => Definition.fromJson(j as Map), )!, tags: json2list(json['tags']) ?? [], note: json['note'] as String?, @@ -60,7 +60,7 @@ class Word { data['language'] = language; if (tags.isNotEmpty) data['tags'] = tags; if (note?.isNotEmpty ?? false) data['note'] = note; - data['uses'] = uses.map((v) => v.toJson()).toList(); + data['definitions'] = definitions.map((v) => v.toJson()).toList(); if (contribution != null) data['contribution'] = contribution!.toJson(); return data; } diff --git a/lib/modules/account/account.dart b/lib/modules/account/account.dart deleted file mode 100644 index b2c4873f..00000000 --- a/lib/modules/account/account.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:avzag/models/language.dart'; -import 'package:avzag/modules/account/widgets/account_tile.dart'; -import 'package:avzag/modules/account/widgets/editor_languages.dart'; -import 'package:avzag/modules/navigation/services/router.gr.dart'; -import 'package:avzag/store.dart'; -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter/material.dart'; - -import 'widgets/sign_in_buttons.dart'; - -class AccountScreen extends StatefulWidget { - const AccountScreen({Key? key}) : super(key: key); - - @override - State createState() => _AccountScreenState(); -} - -class _AccountScreenState extends State { - User? get user => FirebaseAuth.instance.currentUser; - var language = EditorStore.language; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - leading: const AutoLeadingButton(), - title: const Text('Account'), - ), - floatingActionButton: FloatingActionButton( - child: const Icon(Icons.done_all_outlined), - onPressed: () { - EditorStore.language = language; - context.pushRoute(const RootRoute()); - }, - ), - body: ListView( - padding: const EdgeInsets.only(bottom: 76), - children: [ - if (user == null) - SignInButtons( - onSingIn: () => setState(() {}), - ) - else ...[ - AccountTile( - user!, - onSignOut: () => setState(() {}), - ), - EditorLanguages( - GlobalStore.languages.values.whereType(), - onTap: (l) => setState(() { - language = l == language ? null : l; - }), - selected: language, - ), - ], - ], - ), - ); - } -} diff --git a/lib/modules/account/services/crypto.dart b/lib/modules/account/services/crypto.dart deleted file mode 100644 index ffa6c75e..00000000 --- a/lib/modules/account/services/crypto.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:convert'; -import 'dart:math'; - -import 'package:crypto/crypto.dart'; - -String generateNonce([int length = 32]) { - const charset = - '0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._'; - final random = Random.secure(); - return List.generate(length, (_) => charset[random.nextInt(charset.length)]) - .join(); -} - -String sha256ofString(String input) { - final bytes = utf8.encode(input); - final digest = sha256.convert(bytes); - return digest.toString(); -} diff --git a/lib/modules/account/widgets/account_tile.dart b/lib/modules/account/widgets/account_tile.dart deleted file mode 100644 index a702014a..00000000 --- a/lib/modules/account/widgets/account_tile.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter/material.dart'; - -import '../services/signing.dart'; - -class AccountTile extends StatelessWidget { - const AccountTile( - this.user, { - this.onSignOut, - Key? key, - }) : super(key: key); - - final User user; - final VoidCallback? onSignOut; - - @override - Widget build(BuildContext context) { - return ListTile( - leading: CircleAvatar( - backgroundImage: - user.photoURL == null ? null : NetworkImage(user.photoURL!), - backgroundColor: Colors.transparent, - ), - title: Text(user.displayName ?? '[no name]'), - subtitle: Text(user.email ?? '[no email]'), - trailing: onSignOut == null - ? null - : IconButton( - onPressed: () async { - if (await signOut(user) == true) onSignOut?.call(); - }, - icon: const Icon(Icons.logout_outlined), - tooltip: 'Sign out', - ), - ); - } -} diff --git a/lib/modules/account/widgets/editor_languages.dart b/lib/modules/account/widgets/editor_languages.dart deleted file mode 100644 index 3505f391..00000000 --- a/lib/modules/account/widgets/editor_languages.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:avzag/models/language.dart'; -import 'package:avzag/shared/extensions.dart'; -import 'package:avzag/shared/utils.dart'; -import 'package:avzag/shared/widgets/column_card.dart'; -import 'package:avzag/shared/widgets/language_avatar.dart'; -import 'package:flutter/material.dart'; - -class EditorLanguages extends StatelessWidget { - const EditorLanguages( - this.languages, { - required this.onTap, - this.selected, - Key? key, - }) : super(key: key); - - final Iterable languages; - final String? selected; - final ValueSetter onTap; - - @override - Widget build(BuildContext context) { - return ColumnCard( - children: [ - for (final l in languages) - ListTile( - leading: LanguageAvatar(l.name), - title: Text( - l.name.titled, - style: const TextStyle( - fontWeight: FontWeight.w500, - fontSize: 18, - ), - ), - onTap: () => onTap(l.name), - selected: l.name == selected, - trailing: Builder( - builder: (context) { - final contact = l.contact; - return contact == null - ? const SizedBox() - : IconButton( - onPressed: () => openLink(contact), - icon: const Icon(Icons.send_outlined), - tooltip: 'Contact admin', - ); - }, - ), - ), - ], - ); - } -} diff --git a/lib/modules/account/widgets/sign_in_buttons.dart b/lib/modules/account/widgets/sign_in_buttons.dart deleted file mode 100644 index 611e5050..00000000 --- a/lib/modules/account/widgets/sign_in_buttons.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:avzag/shared/modals/snackbar_manager.dart'; -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import '../services/credentials.dart'; -import '../services/signing.dart' as signing; - -class SignInButtons extends StatefulWidget { - const SignInButtons({ - this.onSingIn, - Key? key, - }) : super(key: key); - - final FutureOr Function()? onSingIn; - - @override - State createState() => _SignInButtonsState(); -} - -class _SignInButtonsState extends State { - var loading = false; - - Future signIn( - Future Function() credentialsGetter, - ) async { - setState(() { - loading = true; - }); - try { - if (await signing.signIn(credentialsGetter) == true) { - await widget.onSingIn?.call(); - } - } catch (e) { - showSnackbar(context); - } - setState(() { - loading = false; - }); - } - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(16), - child: loading - ? const Center( - child: SizedBox.square( - dimension: 24, - child: CircularProgressIndicator(), - ), - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ElevatedButton.icon( - onPressed: () => signIn(getGoogleCredentials), - icon: const Icon(Icons.login_outlined), - label: const Text('Sign in with Google'), - ), - if (kIsWeb || Platform.isIOS) ...[ - const SizedBox(height: 16), - ElevatedButton.icon( - onPressed: () => signIn(getAppleCredentials), - icon: const Icon(Icons.login_outlined), - label: const Text('Sign in with Apple'), - ), - ], - ], - ), - ); - } -} diff --git a/lib/modules/dictionary/dictionary.dart b/lib/modules/dictionary/dictionary.dart index 2ddd1b7f..0c11c220 100644 --- a/lib/modules/dictionary/dictionary.dart +++ b/lib/modules/dictionary/dictionary.dart @@ -1,17 +1,16 @@ import 'package:auto_route/auto_route.dart'; -import 'package:avzag/modules/dictionary/widgets/entry_group.dart'; -import 'package:avzag/modules/navigation/navigation.dart'; -import 'package:avzag/modules/navigation/services/router.gr.dart'; -import 'package:avzag/shared/modals/loading_dialog.dart'; -import 'package:avzag/shared/modals/snackbar_manager.dart'; -import 'package:avzag/shared/widgets/caption.dart'; -import 'package:avzag/store.dart'; +import 'package:bazur/models/entry.dart'; +import 'package:bazur/models/word.dart'; +import 'package:bazur/modules/dictionary/widgets/entry_group.dart'; +import 'package:bazur/navigation/router.gr.dart'; +import 'package:bazur/shared/modals/loading_dialog.dart'; +import 'package:bazur/shared/modals/snackbar_manager.dart'; +import 'package:bazur/shared/widgets/caption.dart'; +import 'package:bazur/store.dart'; import 'package:flutter/material.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:provider/provider.dart'; -import 'models/entry.dart'; -import 'models/word.dart'; import 'services/search_controller.dart'; import 'services/word.dart'; import 'widgets/search_toolbar.dart'; @@ -30,7 +29,7 @@ class DictionaryScreenState extends State { firstPageKey: 0, ); late final search = SearchController( - GlobalStore.languages.keys, + GlobalStore.languages, algolia.index('dictionary'), paging.refresh, ); @@ -40,10 +39,10 @@ class DictionaryScreenState extends State { super.initState(); paging.addPageRequestListener( (page) async { - final terms = await search.fetchHits(page); + final terms = await search.fetch(page); if (terms.isEmpty) { paging.appendLastPage([]); - } else if (search.monolingual) { + } else if (search.global) { paging.appendPage(terms, page + 1); } else { paging.appendLastPage(terms); @@ -65,7 +64,7 @@ class DictionaryScreenState extends State { editing ??= Word( null, headword: '', - uses: [], + definitions: [], language: EditorStore.language!, tags: [], forms: [], @@ -123,7 +122,6 @@ class DictionaryScreenState extends State { value: search, builder: (context, _) { return Scaffold( - drawer: const NavigationScreen(), floatingActionButton: EditorStore.editor ? FloatingActionButton( onPressed: edit, @@ -136,33 +134,91 @@ class DictionaryScreenState extends State { body: CustomScrollView( slivers: [ SliverAppBar( - title: const Text('Dictionary'), + leading: IconButton( + onPressed: () async { + await context.pushRoute(const HomeRoute()); + search.setLanguage('', GlobalStore.languages); + }, + tooltip: 'Home', + icon: const Icon(Icons.landscape_outlined), + ), + title: const Text('Bazur'), actions: [ - if (EditorStore.admin) - IconButton( - onPressed: () => setState(() { - search.unverified = !search.unverified; - search.updateQuery(); - }), - icon: Icon( - Icons.unpublished_outlined, - color: search.unverified - ? Theme.of(context).colorScheme.primary - : null, - ), - tooltip: 'Unverified', + IconButton( + onPressed: () => showSnackbar( + context, + icon: Icons.info_outlined, + text: 'Bookmarks are coming soon', ), + icon: const Icon(Icons.bookmarks_outlined), + tooltip: 'Bookmarks', + ), + IconButton( + onPressed: () => showSnackbar( + context, + icon: Icons.info_outlined, + text: 'History is coming soon', + ), + icon: const Icon(Icons.history_outlined), + tooltip: 'History', + ), + IconButton( + onPressed: () async { + await context.pushRoute(const SettingsRoute()); + setState(() {}); + }, + icon: const Icon(Icons.settings_outlined), + tooltip: 'Settings', + ), const SizedBox(width: 4), ], bottom: const PreferredSize( - preferredSize: Size.fromHeight(kToolbarHeight + 3), - child: SearchToolbar(), + preferredSize: Size.fromHeight(kToolbarHeight), + child: SizedBox( + height: kToolbarHeight, + child: SearchToolbar(), + ), ), pinned: true, snap: true, floating: true, forceElevated: true, ), + SliverList( + delegate: SliverChildListDelegate([ + if (EditorStore.admin) ...[ + SwitchListTile( + value: context.read().unverified, + secondary: const Icon(Icons.unpublished_outlined), + title: const Text('Only unverified'), + onChanged: (v) => setState(() { + search.unverified = v; + search.query(); + }), + ), + const Divider(), + ], + Builder( + builder: (context) { + final snapshot = + context.watch().snapshot; + return Caption( + snapshot == null + ? 'Searching...' + : 'Found ${snapshot.nbHits} entries', + icon: Icons.search_outlined, + padding: const EdgeInsets.only( + right: 20, + top: 16, + bottom: 4, + left: 20, + ), + centered: false, + ); + }, + ), + ]), + ), PagedSliverList( pagingController: paging, builderDelegate: PagedChildBuilderDelegate( @@ -170,8 +226,8 @@ class DictionaryScreenState extends State { return EntryGroup( search.getHits(id), onTap: open, - showLanguage: GlobalStore.languages.length > 1 && - !search.monolingual, + showLanguage: + GlobalStore.languages.length > 1 && !search.global, ); }, noItemsFoundIndicatorBuilder: _endCaption, @@ -189,11 +245,14 @@ class DictionaryScreenState extends State { } Widget _endCaption(BuildContext context) { + if (context.watch().snapshot?.nbHits == 0) { + return const SizedBox(); + } return Caption( - search.monolingual - ? 'End of the results' - : 'Showing the first 50 entries', + search.global ? 'End of the results' : 'Showing the first 50 entries', icon: Icons.done_all_outlined, + centered: false, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), ); } } diff --git a/lib/modules/dictionary/services/search_controller.dart b/lib/modules/dictionary/services/search_controller.dart index 541199af..7ecb158d 100644 --- a/lib/modules/dictionary/services/search_controller.dart +++ b/lib/modules/dictionary/services/search_controller.dart @@ -1,41 +1,37 @@ import 'package:algolia/algolia.dart'; +import 'package:bazur/models/entry.dart'; import 'package:flutter/material.dart'; -import '../models/entry.dart'; - class SearchController with ChangeNotifier { SearchController( this._languages, this._index, [ this._onSearch, ]) { - updateQuery(); + query(); } - final Iterable _languages; + Iterable _languages; + final AlgoliaIndexReference _index; final VoidCallback? _onSearch; String _language = ''; String get language => _language; - set language(String value) { - _language = value; - updateQuery(); - notifyListeners(); - } bool _unverified = false; bool get unverified => _unverified; set unverified(bool value) { _unverified = value; - updateQuery(); - notifyListeners(); + query(); } - bool get monolingual => language.isNotEmpty && language != '_'; - + bool get global => language.isNotEmpty; int get length => _tags.length; + AlgoliaQuerySnapshot? _snapshot; + AlgoliaQuerySnapshot? get snapshot => _snapshot; + final Map> _tags = {}; Set getTags(String id) => _tags[id] ?? {}; @@ -48,10 +44,16 @@ class SearchController with ChangeNotifier { late AlgoliaQuery _query = _index.query(''); - void updateQuery([String text = '']) { + void setLanguage(String language, [Iterable? languages]) { + _language = language; + _languages = languages ?? _languages; + query(); + } + + void query([String text = '']) { final parsed = _parseQuery(text); final languages = _generateFilter( - monolingual ? [language] : _languages, + global ? [language] : _languages, 'language', ); _query = _index.query(parsed[0]).filters( @@ -60,41 +62,29 @@ class SearchController with ChangeNotifier { if (unverified) { _query = _query.facetFilter('unverified:true'); } - _query = monolingual + _query = global ? _query.setRestrictSearchableAttributes(['forms']) : _query.setHitsPerPage(50); + _snapshot = null; _tags.clear(); _hits.clear(); _onSearch?.call(); + notifyListeners(); } - Future> fetchHits(int page) async { - var hits = await _query.setPage(page).getObjects().then((s) => s.hits); - if (hits.isNotEmpty && language == '_') { - final original = { - for (final hit in hits) hit.objectID: hit, - }; - final terms = _generateFilter( - hits.map((hit) => hit.data['term'] as String), - 'term', - ); - final languages = _generateFilter(_languages, 'language'); - hits = await _index - .filters('($languages) AND ($terms)') - .getObjects() - .then((s) => s.hits.map((h) => original[h.objectID] ?? h)) - .then((h) => h.toList()); - } - return _organizeHits( - hits.map((h) => Entry.fromAlgoliaHit(h)), + Future> fetch(int page) async { + _snapshot = await _query.setPage(page).getObjects(); + notifyListeners(); + return _organize( + snapshot!.hits.map((h) => Entry.fromAlgoliaHit(h)), ); } - List _organizeHits(Iterable hits) { + List _organize(Iterable hits) { final newIds = []; for (final hit in hits) { - final id = monolingual ? hit.objectID : hit.term; + final id = global ? hit.objectID : hit.translation; _hits.putIfAbsent(id, () { _tags[id] = {}; newIds.add(id); diff --git a/lib/modules/dictionary/services/sharing.dart b/lib/modules/dictionary/services/sharing.dart index cd16e2e4..58dcbc9b 100644 --- a/lib/modules/dictionary/services/sharing.dart +++ b/lib/modules/dictionary/services/sharing.dart @@ -1,16 +1,14 @@ -import 'package:avzag/modules/dictionary/models/sample.dart'; -import 'package:avzag/shared/extensions.dart'; +import 'package:bazur/models/sample.dart'; +import 'package:bazur/models/word.dart'; +import 'package:bazur/shared/extensions.dart'; import 'package:markdown/markdown.dart'; -import '../models/word.dart'; - -String _getWordLink(Word word) => - 'https://avzag.raxys.app/dictionary/${word.id}'; +String _getLink(Word word) => 'https://bazur.raxys.app/${word.id}'; String previewArticle(Word word) => ''' -🌄 Avzag • ${word.language.titled} -🔖 ${word.headword.titled} — ${word.uses.map((u) => u.term.titled).join(', ')} -${_getWordLink(word)}'''; +🌄 Bazur • ${word.language.titled} +🔖 ${word.headword.titled} — ${word.definitions.map((d) => d.translation.titled).join(', ')} +${_getLink(word)}'''; String _cleanMarkdown(String md) => markdownToHtml(md, inlineOnly: true) .replaceAll(RegExp(r'<[^>]*>|&[^;]+;'), '') @@ -27,20 +25,19 @@ String textifyArticle(Word word) { String note(String n) => '📝 ${_cleanMarkdown(n)}'; final article = [ - '🌄 Avzag • ${word.language.titled}', - _getWordLink(word), + '🌄 Bazur • ${word.language.titled}', + _getLink(word), '\n🔖 ${word.headword.titled}', if (word.ipa != null) '🔉 ${word.ipa}', if (word.tags.isNotEmpty) tags(word.tags), if (word.note?.isNotEmpty ?? false) note(word.note!), if (word.forms.isNotEmpty) ...samples(word.forms), - if (word.uses.isNotEmpty) - for (final use in word.uses) ...[ - '\n💡 ${use.term.titled}', - if (use.tags.isNotEmpty) tags(use.tags), - if (use.note?.isNotEmpty ?? false) note(use.note!), - if (use.examples.isNotEmpty) ...samples(use.examples), - ], + for (final d in word.definitions) ...[ + '\n💡${word.definitions.indexOf(d) + 1} ${d.translation.titled}', + if (d.tags.isNotEmpty) tags(d.tags), + if (d.note?.isNotEmpty ?? false) note(d.note!), + if (d.examples.isNotEmpty) ...samples(d.examples), + ], ]; return article.join('\n'); } diff --git a/lib/modules/dictionary/services/word.dart b/lib/modules/dictionary/services/word.dart index 2a4ddd86..87de945b 100644 --- a/lib/modules/dictionary/services/word.dart +++ b/lib/modules/dictionary/services/word.dart @@ -1,13 +1,12 @@ -import 'package:avzag/models/contribution.dart'; -import 'package:avzag/shared/modals/danger_dialog.dart'; -import 'package:avzag/shared/modals/loading_dialog.dart'; -import 'package:avzag/shared/modals/snackbar_manager.dart'; -import 'package:avzag/store.dart'; +import 'package:bazur/models/contribution.dart'; +import 'package:bazur/models/word.dart'; +import 'package:bazur/shared/modals/danger_dialog.dart'; +import 'package:bazur/shared/modals/loading_dialog.dart'; +import 'package:bazur/shared/modals/snackbar_manager.dart'; +import 'package:bazur/store.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; -import '../models/word.dart'; - Future loadWord(String? id) async { if (id == null) return null; final doc = await FirebaseFirestore.instance @@ -48,10 +47,10 @@ void submitWord( Word word, { VoidCallback? after, }) async { - if (word.uses.isEmpty) { + if (word.definitions.isEmpty) { return showSnackbar( context, - text: 'Must have at least one use', + text: 'Must have at least one definition', ); } String? id = word.id; diff --git a/lib/modules/dictionary/widgets/entry_group.dart b/lib/modules/dictionary/widgets/entry_group.dart index f8fa03c7..cd2ed561 100644 --- a/lib/modules/dictionary/widgets/entry_group.dart +++ b/lib/modules/dictionary/widgets/entry_group.dart @@ -1,6 +1,6 @@ -import 'package:avzag/modules/dictionary/models/entry.dart'; -import 'package:avzag/shared/extensions.dart'; -import 'package:avzag/shared/widgets/column_card.dart'; +import 'package:bazur/models/entry.dart'; +import 'package:bazur/shared/extensions.dart'; +import 'package:bazur/shared/widgets/column_card.dart'; import 'package:flutter/material.dart'; import 'entry_tile.dart'; @@ -28,13 +28,13 @@ class EntryGroup extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + padding: const EdgeInsets.fromLTRB(20, 10, 20, 4), child: Row( textBaseline: TextBaseline.alphabetic, crossAxisAlignment: CrossAxisAlignment.baseline, children: [ Text( - groups.first.first.term.titled, + groups.first.first.translation.titled, style: TextStyle( color: theme.caption?.color, fontWeight: FontWeight.w500, @@ -56,8 +56,9 @@ class EntryGroup extends StatelessWidget { ), ), ColumnCard( + margin: const EdgeInsets.symmetric(horizontal: 4), elevation: .5, - margin: EdgeInsets.zero, + shape: null, children: [ for (final g in groups) Column( @@ -69,7 +70,7 @@ class EntryGroup extends StatelessWidget { onTap: () => onTap?.call(g[i]), ), ], - ) + ), ], ), ], diff --git a/lib/modules/dictionary/widgets/entry_tile.dart b/lib/modules/dictionary/widgets/entry_tile.dart index d5f592c6..7b98aa1f 100644 --- a/lib/modules/dictionary/widgets/entry_tile.dart +++ b/lib/modules/dictionary/widgets/entry_tile.dart @@ -1,11 +1,10 @@ -import 'package:avzag/shared/extensions.dart'; -import 'package:avzag/shared/utils.dart'; -import 'package:avzag/shared/widgets/span_icon.dart'; +import 'package:bazur/models/entry.dart'; +import 'package:bazur/shared/extensions.dart'; +import 'package:bazur/shared/utils.dart'; +import 'package:bazur/shared/widgets/span_icon.dart'; import 'package:flutter/material.dart'; -import '../models/entry.dart'; - class EntryTile extends StatelessWidget { final Entry hit; final bool showLanguage; diff --git a/lib/modules/dictionary/widgets/samples_column.dart b/lib/modules/dictionary/widgets/samples_column.dart index bd052753..2a6b0e2b 100644 --- a/lib/modules/dictionary/widgets/samples_column.dart +++ b/lib/modules/dictionary/widgets/samples_column.dart @@ -1,6 +1,6 @@ -import 'package:avzag/shared/utils.dart'; +import 'package:bazur/models/sample.dart'; +import 'package:bazur/shared/utils.dart'; import 'package:flutter/material.dart'; -import '../models/sample.dart'; class SamplesColumn extends StatelessWidget { const SamplesColumn( @@ -23,37 +23,27 @@ class SamplesColumn extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final s in samples) - InkWell( - onLongPress: () { - final text = [ - s.text, - if (s.meaning != null) - inline ? s.meaning!.toUpperCase() : s.meaning - ].join(inline ? ' ' : '\n'); - copyText(context, text); - }, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 4, - ), - child: RichText( - text: TextSpan( - style: theme.bodyText2, - children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: SelectableText.rich( + TextSpan( + style: theme.bodyText2, + children: [ + TextSpan( + text: s.text, + style: styleNative, + ), + if (s.meaning != null) ...[ + TextSpan(text: inline ? ' ' : '\n'), TextSpan( - text: s.text, - style: styleNative, + text: inline ? s.meaning!.toUpperCase() : s.meaning, + style: TextStyle(color: theme.caption?.color), ), - if (s.meaning != null) ...[ - TextSpan(text: inline ? ' ' : '\n'), - TextSpan( - text: inline ? s.meaning!.toUpperCase() : s.meaning, - style: TextStyle(color: theme.caption?.color), - ), - ], ], - ), + ], ), ), ), diff --git a/lib/modules/dictionary/widgets/samples_editor.dart b/lib/modules/dictionary/widgets/samples_editor.dart index 3dd49996..9e7b8d1e 100644 --- a/lib/modules/dictionary/widgets/samples_editor.dart +++ b/lib/modules/dictionary/widgets/samples_editor.dart @@ -1,8 +1,7 @@ -import 'package:avzag/shared/widgets/compact_input.dart'; +import 'package:bazur/models/sample.dart'; +import 'package:bazur/shared/widgets/compact_input.dart'; import 'package:flutter/material.dart'; -import '../models/sample.dart'; - class SamplesEditor extends StatefulWidget { const SamplesEditor( this.title, diff --git a/lib/modules/dictionary/widgets/search_toolbar.dart b/lib/modules/dictionary/widgets/search_toolbar.dart index 0201921e..21726ce5 100644 --- a/lib/modules/dictionary/widgets/search_toolbar.dart +++ b/lib/modules/dictionary/widgets/search_toolbar.dart @@ -1,9 +1,9 @@ import 'dart:async'; -import 'package:avzag/shared/extensions.dart'; -import 'package:avzag/shared/widgets/language_avatar.dart'; -import 'package:avzag/shared/widgets/options_button.dart'; -import 'package:avzag/store.dart'; +import 'package:bazur/shared/extensions.dart'; +import 'package:bazur/shared/widgets/language_avatar.dart'; +import 'package:bazur/shared/widgets/options_button.dart'; +import 'package:bazur/store.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -17,20 +17,20 @@ class SearchToolbar extends StatefulWidget { } class SearchToolbarState extends State { - final inputController = TextEditingController(); + final _input = TextEditingController(); Timer timer = Timer(Duration.zero, () {}); String lastText = ''; @override void initState() { super.initState(); - inputController.addListener(() { - if (lastText == inputController.text) return; + _input.addListener(() { + if (lastText == _input.text) return; setState(() { - lastText = inputController.text; + lastText = _input.text; }); timer.cancel(); - if (inputController.text.isEmpty) { + if (_input.text.isEmpty) { search(); } else { timer = Timer( @@ -47,91 +47,80 @@ class SearchToolbarState extends State { @override void dispose() { - inputController.dispose(); + _input.dispose(); super.dispose(); } - void search() => - context.read().updateQuery(inputController.text); + void search() => context.read().query(_input.text); void setLanguage(String language) { - context.read().language = language; - inputController.clear(); + context.read().setLanguage(language); + _input.clear(); } @override Widget build(BuildContext context) { final search = context.watch(); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Row( - children: [ - OptionsButton( - [ - OptionItem.simple( - Icons.language_outlined, - GlobalStore.languages.length == 1 ? 'English' : 'Multilingual', - onTap: () => setLanguage(''), - ), - OptionItem.simple( - Icons.layers_outlined, - 'Cross-lingual', - onTap: () => setLanguage('_'), - ), - OptionItem.divider(), - for (final l in GlobalStore.languages.keys) - OptionItem.tile( - Transform.scale( - scale: 1.25, - child: LanguageAvatar( - l, - radius: 12, - ), - ), - Text( - l.titled, - style: const TextStyle(fontWeight: FontWeight.w500), + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(width: 4), + OptionsButton( + [ + OptionItem.simple( + Icons.language_outlined, + 'Global', + onTap: () => setLanguage(''), + ), + OptionItem.divider(), + for (final l in GlobalStore.languages) + OptionItem.tile( + Transform.scale( + scale: 1.25, + child: LanguageAvatar( + l, + radius: 12, ), - onTap: () => setLanguage(l), - ) - ], - icon: Builder(builder: (context) { - if (search.language.isEmpty) { - return const Icon(Icons.language_outlined); - } - if (search.language == '_') { - return const Icon(Icons.layers_outlined); - } - return LanguageAvatar(search.language); - }), - ), - Expanded( - child: Padding( - padding: const EdgeInsets.only(left: 20, right: 4), - child: Builder( - builder: (context) { - var label = 'Search '; - label += search.monolingual - ? 'forms in ${search.language.titled}' - : '${search.language.isEmpty ? 'over' : 'across'} the languages'; - return TextField( - controller: inputController, - decoration: InputDecoration( - border: InputBorder.none, - labelText: label, - ), - ); - }, + ), + Text( + l.titled, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + onTap: () => setLanguage(l), ), - ), + ], + tooltip: 'Search mode', + icon: search.language.isEmpty + ? const Icon(Icons.language_outlined) + : LanguageAvatar(search.language), + ), + const SizedBox(width: 20), + Expanded( + child: Builder( + builder: (context) { + var label = 'Search '; + label += search.global + ? 'forms in ${search.language.titled}' + : '${search.language.isEmpty ? 'over' : 'across'} the languages'; + return TextField( + controller: _input, + decoration: InputDecoration( + border: InputBorder.none, + hintText: label, + ), + ); + }, ), - if (inputController.text.isNotEmpty) - IconButton( - onPressed: inputController.clear, - icon: const Icon(Icons.clear_outlined), - ), - ], - ), + ), + const SizedBox(width: 4), + if (_input.text.isNotEmpty) + IconButton( + onPressed: _input.clear, + tooltip: 'Clear', + icon: const Icon(Icons.clear_outlined), + ), + const SizedBox(width: 4), + ], ); } } diff --git a/lib/modules/dictionary/widgets/word_view.dart b/lib/modules/dictionary/widgets/word_view.dart index 7390fb7c..286bad29 100644 --- a/lib/modules/dictionary/widgets/word_view.dart +++ b/lib/modules/dictionary/widgets/word_view.dart @@ -1,9 +1,9 @@ -import 'package:avzag/modules/dictionary/models/word.dart'; -import 'package:avzag/shared/extensions.dart'; -import 'package:avzag/shared/utils.dart'; -import 'package:avzag/shared/widgets/caption.dart'; -import 'package:avzag/shared/widgets/column_card.dart'; -import 'package:avzag/shared/widgets/markdown_text.dart'; +import 'package:bazur/models/word.dart'; +import 'package:bazur/shared/extensions.dart'; +import 'package:bazur/shared/utils.dart'; +import 'package:bazur/shared/widgets/caption.dart'; +import 'package:bazur/shared/widgets/column_card.dart'; +import 'package:bazur/shared/widgets/markdown_text.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -33,14 +33,14 @@ class WordView extends StatelessWidget { vertical: 8, ), children: [ - Text( + SelectableText( word.headword.titled, style: styleNative.copyWith( fontSize: theme.headline5?.fontSize, ), ), if (word.ipa != null) - Text( + SelectableText( '[${word.ipa!}]', style: GoogleFonts.notoSans( textStyle: theme.bodyText1, @@ -60,7 +60,7 @@ class WordView extends StatelessWidget { word.forms, inline: true, ), - for (final u in word.uses) ...[ + for (final d in word.definitions) ...[ ColumnCard( divider: const SizedBox(height: 4), padding: const EdgeInsets.symmetric( @@ -71,34 +71,42 @@ class WordView extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + '${word.definitions.indexOf(d) + 1}', + style: theme.headline6?.copyWith( + color: theme.caption?.color, + ), + ), Expanded( - child: Text( - u.term.titled, - style: theme.headline6, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: SelectableText( + d.translation.titled, + style: theme.headline6, + ), ), ), - if (u.aliases.isNotEmpty) + if (d.aliases.isNotEmpty) Tooltip( - message: u.aliases.join(' • ').titled, - child: Padding( - padding: const EdgeInsets.only(left: 8), - child: Icon( - Icons.label_outlined, - color: Theme.of(context).textTheme.caption?.color, - ), + waitDuration: Duration.zero, + triggerMode: TooltipTriggerMode.tap, + message: d.aliases.join(' • ').titled, + child: Icon( + Icons.label_outlined, + color: Theme.of(context).textTheme.caption?.color, ), ) ], ), - if (u.tags.isNotEmpty) + if (d.tags.isNotEmpty) Text( - u.tags.join(', '), + d.tags.join(', '), style: theme.caption, ), - if (u.note != null) MarkdownText(u.note!), + if (d.note != null) MarkdownText(d.note!), ], ), - if (u.examples.isNotEmpty) SamplesColumn(u.examples), + if (d.examples.isNotEmpty) SamplesColumn(d.examples), ], if (word.contribution != null) const Caption( diff --git a/lib/modules/dictionary/word.dart b/lib/modules/dictionary/word.dart index da1f4341..c6bf1a50 100644 --- a/lib/modules/dictionary/word.dart +++ b/lib/modules/dictionary/word.dart @@ -1,12 +1,12 @@ import 'package:auto_route/auto_route.dart'; -import 'package:avzag/modules/dictionary/services/sharing.dart'; -import 'package:avzag/shared/utils.dart'; -import 'package:avzag/shared/widgets/language_title.dart'; -import 'package:avzag/shared/widgets/options_button.dart'; +import 'package:bazur/models/word.dart'; +import 'package:bazur/modules/dictionary/services/sharing.dart'; +import 'package:bazur/shared/utils.dart'; +import 'package:bazur/shared/widgets/language_title.dart'; +import 'package:bazur/shared/widgets/options_button.dart'; import 'package:flutter/material.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; -import 'models/word.dart'; import 'widgets/word_view.dart'; class WordScreen extends StatelessWidget { diff --git a/lib/modules/dictionary/word_editor.dart b/lib/modules/dictionary/word_editor.dart index fd6a3dde..526791e5 100644 --- a/lib/modules/dictionary/word_editor.dart +++ b/lib/modules/dictionary/word_editor.dart @@ -1,16 +1,16 @@ import 'package:auto_route/auto_route.dart'; -import 'package:avzag/modules/dictionary/widgets/samples_editor.dart'; -import 'package:avzag/shared/modals/danger_dialog.dart'; -import 'package:avzag/shared/widgets/column_card.dart'; -import 'package:avzag/shared/widgets/compact_input.dart'; -import 'package:avzag/shared/widgets/language_title.dart'; -import 'package:avzag/shared/widgets/options_button.dart'; -import 'package:avzag/store.dart'; +import 'package:bazur/models/use.dart'; +import 'package:bazur/models/word.dart'; +import 'package:bazur/modules/dictionary/widgets/samples_editor.dart'; +import 'package:bazur/shared/modals/danger_dialog.dart'; +import 'package:bazur/shared/widgets/column_card.dart'; +import 'package:bazur/shared/widgets/compact_input.dart'; +import 'package:bazur/shared/widgets/language_title.dart'; +import 'package:bazur/shared/widgets/options_button.dart'; +import 'package:bazur/store.dart'; import 'package:flutter/material.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; -import 'models/use.dart'; -import 'models/word.dart'; import 'services/word.dart'; class WordEditorScreen extends StatefulWidget { @@ -88,7 +88,7 @@ class _WordEditorScreenState extends State { divider: null, children: [ CompactInput( - Icons.bookmark_border_outlined, + Icons.label_important_outlined, 'Headword', word.headword, (s) => word.headword = s, @@ -107,7 +107,7 @@ class _WordEditorScreenState extends State { (s) => word.tags = s.split(' '), ), CompactInput( - Icons.note_outlined, + Icons.sticky_note_2_outlined, 'General note', word.note, (s) => word.note = s, @@ -117,24 +117,24 @@ class _WordEditorScreenState extends State { ], ), SamplesEditor('Add a form', word.forms), - for (final u in word.uses) ...[ + for (final d in word.definitions) ...[ ColumnCard( - key: ObjectKey(u), + key: ObjectKey(d), divider: null, children: [ CompactInput( Icons.lightbulb_outlined, - 'Term', - u.term, - (s) => u.term = s, + 'Translation', + d.translation, + (s) => d.translation = s, noEmpty: true, trailing: IconButton( onPressed: () => showDangerDialog( context, () => setState(() { - word.uses.remove(u); + word.definitions.remove(d); }), - 'Delete the use?', + 'Delete the definition?', ), icon: const Icon(Icons.delete_outlined), ), @@ -142,33 +142,33 @@ class _WordEditorScreenState extends State { CompactInput( Icons.label_outlined, 'Aliases', - u.aliases.join(' '), - (s) => u.aliases = s.split(' '), + d.aliases.join(' '), + (s) => d.aliases = s.split(' '), ), CompactInput( Icons.tag_outlined, 'Semantic tags', - u.tags.join(' '), - (s) => u.tags = s.split(' '), + d.tags.join(' '), + (s) => d.tags = s.split(' '), ), CompactInput( - Icons.note_outlined, + Icons.sticky_note_2_outlined, 'Usage note', - u.note, - (s) => u.note = s, + d.note, + (s) => d.note = s, lowercase: false, multiline: true, ), ], ), - SamplesEditor('Add an example', u.examples), + SamplesEditor('Add an example', d.examples), ], Padding( padding: const EdgeInsets.all(8), child: ElevatedButton.icon( onPressed: () => setState(() { - word.uses.add( - Use( + word.definitions.add( + Definition( '', aliases: [], tags: [], @@ -177,7 +177,7 @@ class _WordEditorScreenState extends State { ); }), icon: const Icon(Icons.add_outlined), - label: const Text('Add a use'), + label: const Text('Add a definition'), ), ) ], diff --git a/lib/modules/dictionary/word_loader.dart b/lib/modules/dictionary/word_loader.dart index f573f3bb..55ea3453 100644 --- a/lib/modules/dictionary/word_loader.dart +++ b/lib/modules/dictionary/word_loader.dart @@ -1,8 +1,8 @@ import 'package:auto_route/auto_route.dart'; -import 'package:avzag/modules/dictionary/models/word.dart'; -import 'package:avzag/modules/dictionary/services/word.dart'; -import 'package:avzag/modules/navigation/loader.dart'; -import 'package:avzag/modules/navigation/services/router.gr.dart'; +import 'package:bazur/models/word.dart'; +import 'package:bazur/modules/dictionary/services/word.dart'; +import 'package:bazur/navigation/loader.dart'; +import 'package:bazur/navigation/router.gr.dart'; import 'package:flutter/material.dart'; class WordLoaderScreen extends StatelessWidget { @@ -26,7 +26,7 @@ class WordLoaderScreen extends StatelessWidget { then: (context, word) async { final router = context.router; if (word == null) { - router.navigate(const DictionaryRootRoute()); + router.navigate(const RootRoute()); } else { final router = context.router; await router.replace( @@ -37,7 +37,7 @@ class WordLoaderScreen extends StatelessWidget { ), ); if (router.stack.length < 2) { - router.navigate(const DictionaryRootRoute()); + router.navigate(const RootRoute()); } } }, diff --git a/lib/modules/dictionary/words_diff.dart b/lib/modules/dictionary/words_diff.dart index 24854431..4a895f8b 100644 --- a/lib/modules/dictionary/words_diff.dart +++ b/lib/modules/dictionary/words_diff.dart @@ -1,11 +1,11 @@ import 'package:auto_route/auto_route.dart'; -import 'package:avzag/modules/dictionary/widgets/word_view.dart'; -import 'package:avzag/shared/widgets/caption.dart'; -import 'package:avzag/shared/widgets/language_title.dart'; -import 'package:avzag/shared/widgets/options_button.dart'; +import 'package:bazur/models/word.dart'; +import 'package:bazur/modules/dictionary/widgets/word_view.dart'; +import 'package:bazur/shared/widgets/caption.dart'; +import 'package:bazur/shared/widgets/language_title.dart'; +import 'package:bazur/shared/widgets/options_button.dart'; import 'package:flutter/material.dart'; -import 'models/word.dart'; import 'services/word.dart'; class WordsDiffScreen extends StatelessWidget { diff --git a/lib/modules/home/home.dart b/lib/modules/home/home.dart index f0a6d952..35e98d16 100644 --- a/lib/modules/home/home.dart +++ b/lib/modules/home/home.dart @@ -1,10 +1,8 @@ import 'package:auto_route/auto_route.dart'; -import 'package:avzag/models/language.dart'; -import 'package:avzag/modules/home/widgets/languages_bar.dart'; -import 'package:avzag/modules/navigation/services/router.gr.dart'; -import 'package:avzag/shared/extensions.dart'; -import 'package:avzag/shared/widgets/options_button.dart'; -import 'package:avzag/store.dart'; +import 'package:bazur/models/language.dart'; +import 'package:bazur/modules/home/widgets/languages_bar.dart'; +import 'package:bazur/navigation/router.gr.dart'; +import 'package:bazur/store.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; @@ -18,60 +16,34 @@ class HomeScreen extends StatefulWidget { State createState() => _HomeScreenState(); } -class _LanguageOrdering { - final bool descending; - final String text; - final IconData icon; - late final String field; - - _LanguageOrdering( - this.icon, - this.text, { - String? field, - this.descending = false, - }) { - this.field = field ?? text; - } -} - class _HomeScreenState extends State { var catalogue = []; var tags = {}; var languages = []; var selected = {}; - final inputController = TextEditingController(); - var isLoading = false; - var isMap = false; - - final orderings = [ - _LanguageOrdering(Icons.label_outlined, 'name'), - _LanguageOrdering( - Icons.book_outlined, - 'dictionary', - field: 'stats.dictionary', - descending: true, - ), - ]; - late var ordering = orderings.first; + final _input = TextEditingController(); + var loading = false; + var map = false; + var alpha = true; @override void initState() { super.initState(); - inputController.addListener(filterLanguages); + _input.addListener(filterLanguages); load(); } Future load() async { setState(() { - isLoading = true; + loading = true; }); - var query = FirebaseFirestore.instance + catalogue = await FirebaseFirestore.instance .collection('languages') - .orderBy(ordering.field, descending: ordering.descending); - if (ordering.field != 'name') query = query.orderBy('name'); - - catalogue = await query + .orderBy( + alpha ? 'name' : 'stats.dictionary', + descending: !alpha, + ) .withConverter( fromFirestore: (snapshot, _) => Language.fromJson(snapshot.data()!), toFirestore: (__, _) => {}, @@ -79,7 +51,7 @@ class _HomeScreenState extends State { .get() .then((r) => r.docs.map((d) => d.data()).toList()); - selected = GlobalStore.languages.keys + selected = GlobalStore.languages .map((n) => catalogue.firstWhere((l) => l.name == n)) .toSet(); tags = { @@ -91,17 +63,14 @@ class _HomeScreenState extends State { ].join(' ') }; - isLoading = false; + loading = false; filterLanguages(); } void filterLanguages() { - if (isLoading) return; - final query = inputController.text - .trim() - .toLowerCase() - .split(' ') - .where((s) => s.isNotEmpty); + if (loading) return; + final query = + _input.text.trim().toLowerCase().split(' ').where((s) => s.isNotEmpty); setState(() { languages ..clear() @@ -131,35 +100,30 @@ class _HomeScreenState extends State { titleSpacing: 0, centerTitle: true, leading: IconButton( - tooltip: isMap ? 'Show list' : 'Show map', + tooltip: map ? 'Show list' : 'Show map', icon: Icon( - isMap ? Icons.view_list_outlined : Icons.map_outlined, + map ? Icons.view_list_outlined : Icons.map_outlined, ), onPressed: () => setState(() { - isMap = !isMap; + map = !map; }), ), title: TextField( - controller: inputController, + controller: _input, decoration: const InputDecoration( border: InputBorder.none, hintText: 'Search by names, tags, families', ), ), actions: [ - OptionsButton( - [ - for (final o in orderings) - OptionItem.simple( - o.icon, - o.text.titled, - onTap: () { - ordering = o; - load(); - }, - ), - ], - icon: const Icon(Icons.sort_outlined), + IconButton( + onPressed: () => setState(() { + alpha = !alpha; + load(); + }), + icon: Icon( + alpha ? Icons.sort_by_alpha_outlined : Icons.sort_outlined, + ), ), const SizedBox(width: 4), ], @@ -170,7 +134,7 @@ class _HomeScreenState extends State { ? null : FloatingActionButton( onPressed: () { - GlobalStore.set(objects: selected); + GlobalStore.set(selected.map((l) => l.name).toList()); context.navigateTo(const RootRoute()); }, child: const Icon(Icons.done_all_outlined), @@ -184,12 +148,12 @@ class _HomeScreenState extends State { ), body: Builder( builder: (context) { - if (isLoading) { + if (loading) { return const Center( child: CircularProgressIndicator(), ); } - if (isMap) { + if (map) { return LanguagesMap( onToggle: toggleLanguage, selected: selected, diff --git a/lib/modules/home/widgets/language_card.dart b/lib/modules/home/widgets/language_card.dart index 0ce781a5..95bf5e9d 100644 --- a/lib/modules/home/widgets/language_card.dart +++ b/lib/modules/home/widgets/language_card.dart @@ -1,8 +1,7 @@ -import 'package:avzag/models/language.dart'; -import 'package:avzag/shared/extensions.dart'; -import 'package:avzag/shared/utils.dart'; -import 'package:avzag/shared/widgets/language_flag.dart'; -import 'package:avzag/shared/widgets/span_icon.dart'; +import 'package:bazur/models/language.dart'; +import 'package:bazur/shared/extensions.dart'; +import 'package:bazur/shared/utils.dart'; +import 'package:bazur/shared/widgets/language_flag.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -24,20 +23,6 @@ class LanguageCard extends StatelessWidget { child: InkWell( onTap: onTap, child: ListTile( - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AnimatedOpacity( - opacity: selected ? 1 : .5, - duration:duration200, - child: LanguageFlag( - null, - url: language.flag, - offset: const Offset(20, 0), - ), - ), - ], - ), selected: selected, minVerticalPadding: 16, title: Text( @@ -47,34 +32,17 @@ class LanguageCard extends StatelessWidget { fontWeight: FontWeight.w600, ), ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - language.name.titled, - style: const TextStyle(fontWeight: FontWeight.w500), - ), - if (language.stats != null) - Padding( - padding: const EdgeInsets.only(top: 2), - child: RichText( - overflow: TextOverflow.ellipsis, - text: TextSpan( - style: Theme.of(context).textTheme.caption?.copyWith( - fontSize: 14, - ), - children: [ - const WidgetSpan( - child: SpanIcon(Icons.book_outlined), - ), - TextSpan( - text: language.stats!.dictionary.toString(), - ), - ], - ), - ), - ), - ], + subtitle: Text( + '${language.name.titled} • ${language.dictionary}', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + trailing: Center( + widthFactor: .4, + child: AnimatedOpacity( + opacity: selected ? 1 : .4, + duration: duration200, + child: LanguageFlag(language.name), + ), ), ), ), diff --git a/lib/modules/home/widgets/languages_bar.dart b/lib/modules/home/widgets/languages_bar.dart index 058133ab..e69cf39e 100644 --- a/lib/modules/home/widgets/languages_bar.dart +++ b/lib/modules/home/widgets/languages_bar.dart @@ -1,7 +1,7 @@ -import 'package:avzag/models/language.dart'; -import 'package:avzag/shared/extensions.dart'; -import 'package:avzag/shared/utils.dart'; -import 'package:avzag/shared/widgets/language_avatar.dart'; +import 'package:bazur/models/language.dart'; +import 'package:bazur/shared/extensions.dart'; +import 'package:bazur/shared/utils.dart'; +import 'package:bazur/shared/widgets/language_avatar.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -57,8 +57,7 @@ class _LanguagesBarState extends State { padding: const EdgeInsets.only(right: 4), child: InputChip( avatar: LanguageAvatar( - null, - url: language.flag, + language.name, radius: 12, ), label: Text( diff --git a/lib/modules/home/widgets/languages_map.dart b/lib/modules/home/widgets/languages_map.dart index 4627347d..25e3f78d 100644 --- a/lib/modules/home/widgets/languages_map.dart +++ b/lib/modules/home/widgets/languages_map.dart @@ -1,8 +1,8 @@ -import 'package:avzag/models/language.dart'; -import 'package:avzag/modules/home/services/mapbox.dart'; -import 'package:avzag/shared/extensions.dart'; -import 'package:avzag/shared/utils.dart'; -import 'package:avzag/shared/widgets/language_avatar.dart'; +import 'package:bazur/models/language.dart'; +import 'package:bazur/modules/home/services/mapbox.dart'; +import 'package:bazur/shared/extensions.dart'; +import 'package:bazur/shared/utils.dart'; +import 'package:bazur/shared/widgets/language_avatar.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map_marker_cluster/flutter_map_marker_cluster.dart'; @@ -78,10 +78,7 @@ class LanguagesMap extends StatelessWidget { onTap: () => onToggle(language), child: Padding( padding: const EdgeInsets.all(2), - child: LanguageAvatar( - null, - url: language.flag, - ), + child: LanguageAvatar(language.name), ), ), ), diff --git a/lib/modules/navigation/navigation.dart b/lib/modules/navigation/navigation.dart deleted file mode 100644 index 87c7c553..00000000 --- a/lib/modules/navigation/navigation.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:avzag/modules/navigation/widgets/github_tile.dart'; -import 'package:avzag/modules/navigation/widgets/module_tile.dart'; -import 'package:avzag/modules/navigation/widgets/stores_buttons.dart'; -import 'package:avzag/shared/extensions.dart'; -import 'package:avzag/shared/utils.dart'; -import 'package:avzag/shared/widgets/column_card.dart'; -import 'package:avzag/shared/widgets/expandable_tile.dart'; -import 'package:avzag/shared/widgets/raxys.dart'; -import 'package:avzag/shared/widgets/span_icon.dart'; -import 'package:avzag/store.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import 'services/modules.dart'; -import 'services/router.gr.dart'; - -class NavigationScreen extends StatelessWidget { - const NavigationScreen({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Drawer( - child: Material( - color: Theme.of(context).scaffoldBackgroundColor, - child: ListView( - children: [ - ExpandableTile( - header: ListTile( - leading: const Raxys( - opacity: .1, - scale: 7, - ), - title: Text( - 'Ævzag', - style: Theme.of(context).textTheme.headline6, - ), - ), - body: ColumnCard( - divider: null, - margin: const EdgeInsets.only(bottom: 12), - children: [ - ListTile( - leading: const Icon(Icons.send_outlined), - title: const Text('Developer Contact'), - subtitle: const Text('Raxys Studios'), - onTap: () => openLink('https://t.me/raxysstudios'), - ), - const GitHubTile(), - SwitchListTile( - title: const Text('Editor Mode'), - subtitle: EditorStore.editor - ? Row( - children: [ - if (EditorStore.admin) - const SpanIcon(Icons.verified_user_outlined), - Text(EditorStore.language!.titled), - ], - ) - : null, - value: EditorStore.editor, - secondary: const Icon(Icons.edit_outlined), - onChanged: (e) => context.pushRoute(const AccountRoute()), - ), - if (kIsWeb) ...[ - const Divider(), - const StoresButtons(), - ], - ], - ), - ), - ColumnCard( - divider: null, - margin: EdgeInsets.zero, - children: [ - for (final m in modules) ModuleTile(m), - ], - ) - ], - ), - ), - ); - } -} diff --git a/lib/modules/navigation/services/modules.dart b/lib/modules/navigation/services/modules.dart deleted file mode 100644 index fd2eecf4..00000000 --- a/lib/modules/navigation/services/modules.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart'; - -import 'router.gr.dart'; - -class NavModule { - const NavModule( - this.icon, - this.text, [ - this.route, - ]); - - final IconData icon; - final String text; - final PageRouteInfo? route; -} - -const modules = [ - NavModule( - Icons.home_outlined, - 'home', - HomeRoute(), - ), - NavModule( - Icons.book_outlined, - 'dictionary', - DictionaryRootRoute(), - ), - NavModule(Icons.music_note_outlined, 'phonology'), - NavModule(Icons.switch_left_outlined, 'converter'), - NavModule(Icons.forum_outlined, 'phrasebook'), - NavModule(Icons.local_library_outlined, 'bootcamp'), -]; diff --git a/lib/modules/navigation/services/root_guard.dart b/lib/modules/navigation/services/root_guard.dart deleted file mode 100644 index 9d8cf355..00000000 --- a/lib/modules/navigation/services/root_guard.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:avzag/store.dart'; - -import 'modules.dart'; -import 'router.gr.dart'; - -class RootGuard extends AutoRouteGuard { - @override - void onNavigation(NavigationResolver resolver, StackRouter router) { - var route = - router.hasEntries ? const DictionaryRootRoute() : const HomeRoute(); - final saved = prefs.getString('module'); - for (final m in modules) { - if (saved == m.text && m.route != null) { - route = m.route!; - break; - } - } - if (saved == null && router.hasEntries) { - prefs.setString('module', 'dictionary'); - } - router.pushAndPopUntil(route, predicate: (_) => true); - } -} diff --git a/lib/modules/navigation/widgets/github_tile.dart b/lib/modules/navigation/widgets/github_tile.dart deleted file mode 100644 index b52fe60a..00000000 --- a/lib/modules/navigation/widgets/github_tile.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:avzag/shared/utils.dart'; -import 'package:flutter/material.dart'; -import 'package:package_info_plus/package_info_plus.dart'; - -class GitHubTile extends StatefulWidget { - const GitHubTile({Key? key}) : super(key: key); - - @override - State createState() => _GitHubTileState(); -} - -class _GitHubTileState extends State { - var info = 'Loading...'; - - @override - void initState() { - super.initState(); - PackageInfo.fromPlatform().then( - (package) => setState(() { - info = 'v${package.version} • b${package.buildNumber}'; - }), - ); - } - - @override - Widget build(BuildContext context) { - return ListTile( - leading: const Icon(Icons.code_outlined), - title: const Text('GitHub Repository'), - subtitle: Text(info), - onTap: () => openLink( - 'https://github.com/raxysstudios/avzag', - ), - ); - } -} diff --git a/lib/modules/navigation/widgets/module_tile.dart b/lib/modules/navigation/widgets/module_tile.dart deleted file mode 100644 index cbcfceaa..00000000 --- a/lib/modules/navigation/widgets/module_tile.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:auto_route/auto_route.dart'; -import 'package:avzag/modules/navigation/services/modules.dart'; -import 'package:avzag/shared/extensions.dart'; -import 'package:avzag/store.dart'; -import 'package:flutter/material.dart'; - -import '../services/router.gr.dart'; - -class ModuleTile extends StatelessWidget { - const ModuleTile( - this.module, { - Key? key, - }) : super(key: key); - - final NavModule module; - - @override - Widget build(BuildContext context) { - return ListTile( - leading: Icon(module.icon), - title: Text( - module.text.titled, - style: const TextStyle(fontSize: 18), - ), - trailing: - module.route == null ? const Icon(Icons.construction_outlined) : null, - selected: context.router.currentPath.startsWith(module.route?.path ?? ''), - onTap: () async { - if (module.route != const HomeRoute()) { - await prefs.setString('module', module.text); - } - context.pushRoute(module.route!); - }, - enabled: module.route != null, - ); - } -} diff --git a/lib/modules/navigation/widgets/stores_buttons.dart b/lib/modules/navigation/widgets/stores_buttons.dart deleted file mode 100644 index c23ab4d5..00000000 --- a/lib/modules/navigation/widgets/stores_buttons.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:avzag/shared/utils.dart'; -import 'package:flutter/material.dart'; - -class StoresButtons extends StatelessWidget { - const StoresButtons({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return ListTile( - leading: const Icon(Icons.get_app_outlined), - title: Row( - children: [ - Expanded( - child: TextButton( - onPressed: () => openLink( - 'https://play.google.com/store/apps/details?id=com.alkaitagi.avzag', - ), - child: const Text('Google Play'), - ), - ), - Expanded( - child: TextButton( - onPressed: () => openLink( - 'https://apps.apple.com/app/avzag-languages-of-caucasus/id1603226004', - ), - child: const Text('App Store'), - ), - ), - ], - ), - ); - } -} diff --git a/lib/modules/account/services/credentials.dart b/lib/modules/settings/services/credentials.dart similarity index 76% rename from lib/modules/account/services/credentials.dart rename to lib/modules/settings/services/credentials.dart index e1158293..e665e80d 100644 --- a/lib/modules/account/services/credentials.dart +++ b/lib/modules/settings/services/credentials.dart @@ -1,10 +1,12 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:crypto/crypto.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/foundation.dart'; import 'package:google_sign_in/google_sign_in.dart'; import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple; -import 'crypto.dart'; - Future getGoogleCredentials() async { final user = await GoogleSignIn().signIn(); if (user != null) { @@ -48,3 +50,17 @@ Future getAppleCredentials() async { } return null; } + +String generateNonce([int length = 32]) { + const charset = + '0123456789ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz-._'; + final random = Random.secure(); + return List.generate(length, (_) => charset[random.nextInt(charset.length)]) + .join(); +} + +String sha256ofString(String input) { + final bytes = utf8.encode(input); + final digest = sha256.convert(bytes); + return digest.toString(); +} diff --git a/lib/modules/account/services/signing.dart b/lib/modules/settings/services/signing.dart similarity index 77% rename from lib/modules/account/services/signing.dart rename to lib/modules/settings/services/signing.dart index bfc644d5..1127ae38 100644 --- a/lib/modules/account/services/signing.dart +++ b/lib/modules/settings/services/signing.dart @@ -10,11 +10,9 @@ Future signIn( return true; } -Future signOut([User? user]) async { - final provider = (user ?? FirebaseAuth.instance.currentUser) - ?.providerData - .first - .providerId; +Future signOut() async { + final provider = + FirebaseAuth.instance.currentUser?.providerData.first.providerId; if (provider == null) return false; await FirebaseAuth.instance.signOut(); diff --git a/lib/modules/settings/settings.dart b/lib/modules/settings/settings.dart new file mode 100644 index 00000000..537caae0 --- /dev/null +++ b/lib/modules/settings/settings.dart @@ -0,0 +1,70 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:bazur/navigation/router.gr.dart'; +import 'package:bazur/shared/utils.dart'; +import 'package:bazur/shared/widgets/raxys.dart'; +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'widgets/account_tile.dart'; +import 'widgets/editor_mode_card.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({Key? key}) : super(key: key); + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () => context.navigateTo(const RootRoute()), + icon: const Icon(Icons.arrow_back_outlined), + ), + title: const Text('Settings'), + actions: [ + IconButton( + onPressed: () => openLink('https://raxys.app'), + tooltip: 'Made with honor in North Caucasus', + icon: const Raxys(), + ), + const SizedBox(width: 4), + ], + ), + body: ListView( + padding: const EdgeInsets.only(bottom: 76), + children: [ + const AccountTile(), + const EditorModeCard(), + ListTile( + leading: const Icon(Icons.send_outlined), + title: const Text('Developer contact'), + subtitle: const Text('Raxys Studios'), + onTap: () => openLink('https://t.me/raxysstudios'), + ), + FutureBuilder( + future: PackageInfo.fromPlatform(), + builder: (context, status) { + final p = status.data; + return ListTile( + leading: const Icon(Icons.code_outlined), + title: const Text('GitHub repository'), + subtitle: Text( + p == null + ? 'Loading...' + : 'v${p.version} • b${p.buildNumber}', + ), + onTap: () => openLink( + 'https://github.com/raxysstudios/bazur', + ), + ); + }, + ), + ], + ), + ); + } +} diff --git a/lib/modules/settings/widgets/account_tile.dart b/lib/modules/settings/widgets/account_tile.dart new file mode 100644 index 00000000..61d8079f --- /dev/null +++ b/lib/modules/settings/widgets/account_tile.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:bazur/shared/modals/snackbar_manager.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../services/credentials.dart'; +import '../services/signing.dart'; + +class AccountTile extends StatefulWidget { + const AccountTile({Key? key}) : super(key: key); + + @override + State createState() => _AccountTileState(); +} + +class _AccountTileState extends State { + late final StreamSubscription _authStream; + var loading = false; + + @override + void initState() { + super.initState(); + _authStream = FirebaseAuth.instance.authStateChanges().listen( + (_) => setState(() {}), + ); + } + + @override + void dispose() { + _authStream.cancel(); + super.dispose(); + } + + Future _signIn( + Future Function() credentialsGetter, + ) async { + setState(() { + loading = true; + }); + try { + await signIn(credentialsGetter); + } catch (e) { + showSnackbar(context); + } + setState(() { + loading = false; + }); + } + + @override + Widget build(BuildContext context) { + final user = FirebaseAuth.instance.currentUser; + if (user == null) { + return loading + ? const Padding( + padding: EdgeInsets.all(16), + child: Center( + child: SizedBox.square( + dimension: 24, + child: CircularProgressIndicator(), + ), + ), + ) + : Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: TextButton.icon( + onPressed: () => _signIn(getGoogleCredentials), + icon: const Icon(Icons.login_outlined), + label: const Text('Sign in with Google'), + ), + ), + if (kIsWeb || Platform.isIOS) + Padding( + padding: const EdgeInsets.all(8), + child: TextButton.icon( + onPressed: () => _signIn(getAppleCredentials), + icon: const Icon(Icons.login_outlined), + label: const Text('Sign in with Apple'), + ), + ), + ], + ); + } + return ListTile( + leading: CircleAvatar( + backgroundImage: + user.photoURL == null ? null : NetworkImage(user.photoURL!), + backgroundColor: Colors.transparent, + ), + title: Text(user.displayName ?? '[no name]'), + subtitle: Text(user.email ?? '[no email]'), + trailing: const IconButton( + onPressed: signOut, + icon: Icon(Icons.logout_outlined), + tooltip: 'Sign out', + ), + ); + } +} diff --git a/lib/modules/settings/widgets/editor_mode_card.dart b/lib/modules/settings/widgets/editor_mode_card.dart new file mode 100644 index 00000000..4a787e5d --- /dev/null +++ b/lib/modules/settings/widgets/editor_mode_card.dart @@ -0,0 +1,118 @@ +import 'dart:async'; + +import 'package:bazur/models/language.dart'; +import 'package:bazur/shared/extensions.dart'; +import 'package:bazur/shared/utils.dart'; +import 'package:bazur/shared/widgets/column_card.dart'; +import 'package:bazur/shared/widgets/language_avatar.dart'; +import 'package:bazur/shared/widgets/span_icon.dart'; +import 'package:bazur/store.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; + +class EditorModeCard extends StatefulWidget { + const EditorModeCard({Key? key}) : super(key: key); + + @override + State createState() => _EditorModeCardState(); +} + +class _EditorModeCardState extends State { + late final StreamSubscription _authStream; + var adminable = []; + List? languages; + + @override + void initState() { + super.initState(); + _authStream = FirebaseAuth.instance.authStateChanges().listen( + (_) => updateAdminable(), + ); + } + + @override + void dispose() { + _authStream.cancel(); + super.dispose(); + } + + void updateAdminable() async { + if (languages == null) { + languages = []; + for (final l in GlobalStore.languages) { + await FirebaseFirestore.instance + .doc('languages/$l') + .withConverter( + fromFirestore: (snapshot, _) => + Language.fromJson(snapshot.data()!), + toFirestore: (_, __) => {}, + ) + .get() + .then((d) { + final l = d.data(); + if (l != null) languages!.add(l); + }); + } + } + adminable = await EditorStore.getAdminable(); + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return ColumnCard( + margin: const EdgeInsets.symmetric(vertical: 12), + children: [ + ListTile( + leading: const Icon(Icons.edit_outlined), + title: const Text('Editor mode'), + subtitle: Row( + children: [ + if (EditorStore.language == null) + Text( + EditorStore.user == null + ? 'Sign in to contribute' + : 'Select language below', + style: const TextStyle(fontStyle: FontStyle.italic), + ) + else ...[ + if (EditorStore.admin) + const SpanIcon(Icons.verified_user_outlined), + Text(EditorStore.language!.titled), + ] + ], + ), + ), + if (EditorStore.user != null && languages != null) + Padding( + padding: const EdgeInsets.all(8), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final l in languages!) + InputChip( + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + avatar: LanguageAvatar(l.name), + label: Text(l.name.titled), + onDeleted: + l.contact == null ? null : () => openLink(l.contact!), + deleteButtonTooltipMessage: 'Contact', + deleteIcon: const Icon( + Icons.help_outlined, + size: 18, + ), + tooltip: l.endonym.titled, + selected: l.name == EditorStore.language, + onSelected: (s) => setState(() { + EditorStore.language = s ? l.name : null; + }), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/modules/navigation/loader.dart b/lib/navigation/loader.dart similarity index 95% rename from lib/modules/navigation/loader.dart rename to lib/navigation/loader.dart index e661b8f9..85403611 100644 --- a/lib/modules/navigation/loader.dart +++ b/lib/navigation/loader.dart @@ -1,7 +1,7 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; -import 'package:avzag/shared/modals/loading_dialog.dart'; +import 'package:bazur/shared/modals/loading_dialog.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; diff --git a/lib/navigation/root_guard.dart b/lib/navigation/root_guard.dart new file mode 100644 index 00000000..c1dea8aa --- /dev/null +++ b/lib/navigation/root_guard.dart @@ -0,0 +1,15 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:bazur/store.dart'; + +import 'router.gr.dart'; + +class RootGuard extends AutoRouteGuard { + @override + void onNavigation(NavigationResolver resolver, StackRouter router) { + if (prefs.getStringList('languages')?.isNotEmpty ?? false) { + resolver.next(); + } else { + router.replaceAll([const HomeRoute()]); + } + } +} diff --git a/lib/modules/navigation/services/route_builders.dart b/lib/navigation/route_builders.dart similarity index 100% rename from lib/modules/navigation/services/route_builders.dart rename to lib/navigation/route_builders.dart diff --git a/lib/modules/navigation/services/router.dart b/lib/navigation/router.dart similarity index 64% rename from lib/modules/navigation/services/router.dart rename to lib/navigation/router.dart index 23fbd741..b551b2a4 100644 --- a/lib/modules/navigation/services/router.dart +++ b/lib/navigation/router.dart @@ -1,11 +1,11 @@ import 'package:auto_route/auto_route.dart'; -import 'package:avzag/modules/account/account.dart'; -import 'package:avzag/modules/dictionary/dictionary.dart'; -import 'package:avzag/modules/dictionary/word.dart'; -import 'package:avzag/modules/dictionary/word_editor.dart'; -import 'package:avzag/modules/dictionary/word_loader.dart'; -import 'package:avzag/modules/dictionary/words_diff.dart'; -import 'package:avzag/modules/home/home.dart'; +import 'package:bazur/modules/dictionary/dictionary.dart'; +import 'package:bazur/modules/dictionary/word.dart'; +import 'package:bazur/modules/dictionary/word_editor.dart'; +import 'package:bazur/modules/dictionary/word_loader.dart'; +import 'package:bazur/modules/dictionary/words_diff.dart'; +import 'package:bazur/modules/home/home.dart'; +import 'package:bazur/modules/settings/settings.dart'; import 'root_guard.dart'; import 'route_builders.dart'; @@ -18,34 +18,11 @@ import 'route_builders.dart'; page: EmptyRouterScreen, name: 'RootRoute', guards: [RootGuard], - ), - AutoRoute( - path: '/account', - page: AccountScreen, - ), - AutoRoute( - path: '/home', - page: HomeScreen, - ), - AutoRoute( - path: '/dictionary', - page: EmptyRouterScreen, - name: 'DictionaryRootRoute', children: [ AutoRoute( path: '', page: DictionaryScreen, ), - CustomRoute( - path: ':id', - page: WordLoaderScreen, - customRouteBuilder: dialogRouteBuilder, - ), - CustomRoute( - path: ':id', - page: WordScreen, - customRouteBuilder: sheetRouteBuilder, - ), CustomRoute( path: 'editor', page: WordEditorScreen, @@ -56,9 +33,27 @@ import 'route_builders.dart'; page: WordsDiffScreen, customRouteBuilder: sheetRouteBuilder, ), + CustomRoute( + path: ':id', + page: WordLoaderScreen, + customRouteBuilder: dialogRouteBuilder, + ), + CustomRoute( + path: ':id', + page: WordScreen, + customRouteBuilder: sheetRouteBuilder, + ), ], ), - RedirectRoute(path: '*', redirectTo: '/') + AutoRoute( + path: '/home', + page: HomeScreen, + ), + AutoRoute( + path: '/settings', + page: SettingsScreen, + ), + RedirectRoute(path: '*', redirectTo: '/'), ], ) class $AppRouter {} diff --git a/lib/shared/utils.dart b/lib/shared/utils.dart index 5288a0cf..b4172379 100644 --- a/lib/shared/utils.dart +++ b/lib/shared/utils.dart @@ -1,4 +1,4 @@ -import 'package:avzag/shared/modals/snackbar_manager.dart'; +import 'package:bazur/shared/modals/snackbar_manager.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; diff --git a/lib/shared/widgets/caption.dart b/lib/shared/widgets/caption.dart index a657269e..774c8ebb 100644 --- a/lib/shared/widgets/caption.dart +++ b/lib/shared/widgets/caption.dart @@ -7,19 +7,22 @@ class Caption extends StatelessWidget { this.text, { this.icon, this.padding = const EdgeInsets.all(16), + this.centered = true, Key? key, }) : super(key: key); final String text; final IconData? icon; final EdgeInsets padding; + final bool centered; @override Widget build(BuildContext context) { return Padding( padding: padding, child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: + centered ? MainAxisAlignment.center : MainAxisAlignment.start, children: [ if (icon != null) SpanIcon( diff --git a/lib/shared/widgets/column_card.dart b/lib/shared/widgets/column_card.dart index ecb3ef99..2658b8e0 100644 --- a/lib/shared/widgets/column_card.dart +++ b/lib/shared/widgets/column_card.dart @@ -10,12 +10,14 @@ class ColumnCard extends StatelessWidget { this.divider = const Divider(), this.margin = const EdgeInsets.only(top: 12), this.padding = EdgeInsets.zero, + this.shape = const RoundedRectangleBorder(), Key? key, }) : super(key: key); final EdgeInsets margin; final EdgeInsets padding; final double? elevation; + final ShapeBorder? shape; final String? title; final String? subtitle; final Widget? divider; @@ -27,7 +29,7 @@ class ColumnCard extends StatelessWidget { return Card( elevation: elevation, margin: margin, - shape: const RoundedRectangleBorder(), + shape: shape, child: Padding( padding: padding, child: Column( diff --git a/lib/shared/widgets/expandable_tile.dart b/lib/shared/widgets/expandable_tile.dart deleted file mode 100644 index 80c1568c..00000000 --- a/lib/shared/widgets/expandable_tile.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/material.dart'; - -class ExpandableTile extends StatefulWidget { - const ExpandableTile({ - required this.header, - required this.body, - Key? key, - }) : super(key: key); - - final Widget header; - final Widget body; - - @override - State createState() => _ExpandableTileState(); -} - -class _ExpandableTileState extends State { - var isExpanded = false; - - @override - Widget build(BuildContext context) { - return ExpansionPanelList( - elevation: 0, - expansionCallback: (index, value) => setState(() { - isExpanded = !isExpanded; - }), - expandedHeaderPadding: EdgeInsets.zero, - children: [ - ExpansionPanel( - backgroundColor: Colors.transparent, - isExpanded: isExpanded, - canTapOnHeader: true, - headerBuilder: (_, __) => widget.header, - body: widget.body, - ), - ], - ); - } -} diff --git a/lib/shared/widgets/language_avatar.dart b/lib/shared/widgets/language_avatar.dart index 079080b1..88782553 100644 --- a/lib/shared/widgets/language_avatar.dart +++ b/lib/shared/widgets/language_avatar.dart @@ -1,4 +1,3 @@ -import 'package:avzag/store.dart'; import 'package:flutter/material.dart'; class LanguageAvatar extends StatelessWidget { @@ -15,11 +14,11 @@ class LanguageAvatar extends StatelessWidget { @override Widget build(BuildContext context) { - final url = this.url ?? GlobalStore.languages[language]?.flag; - if (url == null) return const Icon(Icons.flag_outlined); return CircleAvatar( radius: radius, - backgroundImage: NetworkImage(url), + foregroundImage: NetworkImage( + 'https://firebasestorage.googleapis.com/v0/b/avzagapp.appspot.com/o/flags%2F$language.png?alt=media', + ), backgroundColor: Colors.transparent, ); } diff --git a/lib/shared/widgets/language_flag.dart b/lib/shared/widgets/language_flag.dart index 56a1fc11..617df664 100644 --- a/lib/shared/widgets/language_flag.dart +++ b/lib/shared/widgets/language_flag.dart @@ -1,5 +1,4 @@ import 'dart:math'; -import 'package:avzag/store.dart'; import 'package:flutter/material.dart'; class LanguageFlag extends StatelessWidget { @@ -24,8 +23,6 @@ class LanguageFlag extends StatelessWidget { @override Widget build(BuildContext context) { - final url = this.url ?? GlobalStore.languages[language]?.flag; - if (url == null) return const SizedBox(); return Transform.translate( offset: offset, child: Transform.rotate( @@ -33,7 +30,7 @@ class LanguageFlag extends StatelessWidget { child: Transform.scale( scale: scale, child: Image.network( - url, + 'https://firebasestorage.googleapis.com/v0/b/avzagapp.appspot.com/o/flags%2F$language.png?alt=media', repeat: ImageRepeat.repeatX, fit: BoxFit.contain, width: width, diff --git a/lib/shared/widgets/language_title.dart b/lib/shared/widgets/language_title.dart index 6a52d545..d42e9e6d 100644 --- a/lib/shared/widgets/language_title.dart +++ b/lib/shared/widgets/language_title.dart @@ -1,4 +1,4 @@ -import 'package:avzag/shared/extensions.dart'; +import 'package:bazur/shared/extensions.dart'; import 'package:flutter/material.dart'; import 'language_flag.dart'; @@ -19,7 +19,7 @@ class LanguageTitle extends StatelessWidget { Align( alignment: Alignment.centerRight, child: Opacity( - opacity: .5, + opacity: .4, child: LanguageFlag(language), ), ), diff --git a/lib/shared/widgets/options_button.dart b/lib/shared/widgets/options_button.dart index 896d50fe..58231da2 100644 --- a/lib/shared/widgets/options_button.dart +++ b/lib/shared/widgets/options_button.dart @@ -49,10 +49,12 @@ class OptionsButton extends StatelessWidget { const OptionsButton( this.options, { this.icon = const Icon(Icons.more_vert_outlined), + this.tooltip, Key? key, }) : super(key: key); final Widget icon; + final String? tooltip; final List options; PopupMenuEntry _getMenuEntry(int i) { @@ -71,6 +73,7 @@ class OptionsButton extends StatelessWidget { data: const ListTileThemeData(horizontalTitleGap: 0), child: PopupMenuButton( icon: icon, + tooltip: tooltip, onSelected: (i) => options[i].onTap?.call(), itemBuilder: (context) { return [ diff --git a/lib/shared/widgets/raxys.dart b/lib/shared/widgets/raxys.dart index 4c01a4eb..f76d5fa0 100644 --- a/lib/shared/widgets/raxys.dart +++ b/lib/shared/widgets/raxys.dart @@ -3,8 +3,8 @@ import 'package:flutter/material.dart'; class Raxys extends StatelessWidget { const Raxys({ this.size = 24, - this.opacity = 1, - this.scale = 1, + this.opacity = .1, + this.scale = 7, Key? key, }) : super(key: key); diff --git a/lib/store.dart b/lib/store.dart index 7072df53..2e85aa21 100644 --- a/lib/store.dart +++ b/lib/store.dart @@ -1,8 +1,6 @@ import 'package:algolia/algolia.dart'; -import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'models/language.dart'; import 'shared/utils.dart'; late final Algolia algolia; @@ -48,39 +46,16 @@ class EditorStore { } class GlobalStore { - static Map languages = {}; + static var languages = []; - static void set({ - Iterable? names, - Iterable? objects, - }) { - if (objects != null) { - languages = {for (final l in objects) l.name: l}; - } else if (names != null) { - languages = {for (final l in names) l: null}; - } - if (!languages.containsKey(EditorStore.language)) { + static void set(List languages) { + GlobalStore.languages = [...languages]; + if (!languages.contains(EditorStore.language)) { EditorStore.language = null; } - prefs.setStringList('languages', languages.keys.toList()); + prefs.setStringList('languages', languages); } - static void init([List? names]) { - set( - names: names ?? prefs.getStringList('languages') ?? ['aghul'], - ); - for (final l in languages.keys) { - FirebaseFirestore.instance - .doc('languages/$l') - .withConverter( - fromFirestore: (snapshot, _) => Language.fromJson(snapshot.data()!), - toFirestore: (_, __) => {}, - ) - .get() - .then((r) { - final l = r.data(); - if (l != null) languages[l.name] = l; - }); - } - } + static void init([List? names]) => + set(prefs.getStringList('languages') ?? ['aghul']); } diff --git a/pubspec.yaml b/pubspec.yaml index c77beb57..1b76c4a5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ -name: avzag -description: Extensible parallel language compendium +name: bazur +description: Extensible parallel dictionary publish_to: "none" -version: 1.0.6+69 +version: 1.1.1+71 environment: sdk: ">=2.13.0 <3.0.0" diff --git a/web/index.html b/web/index.html index 09e2a80c..72986742 100644 --- a/web/index.html +++ b/web/index.html @@ -7,14 +7,14 @@ - + - Avzag + Bazur diff --git a/web/manifest.json b/web/manifest.json index d00d6130..2e253173 100644 --- a/web/manifest.json +++ b/web/manifest.json @@ -1,6 +1,6 @@ { - "name": "Avzag", - "short_name": "Avzag", + "name": "Bazur", + "short_name": "Bazur", "start_url": ".", "display": "standalone", "background_color": "#303030",