From 7fedbe260c6e432ff3b5c406b06b4533d5474435 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Wed, 21 Aug 2024 13:36:58 +0200 Subject: [PATCH] topics intermediate --- app/lib/core/routing/routes.dart | 12 + app/lib/core/routing/routes.g.dart | 22 + .../widgets/sheets/view_tabs_sheet.dart | 51 +- .../topics/data/database/daos/tab.dart | 44 + .../topics/data/database/daos/tab.g.dart | 6 + .../topics/data/database/daos/topic.dart | 53 + .../topics/data/database/daos/topic.g.dart | 6 + .../topics/data/database/database.dart | 27 + .../topics/data/database/database.drift | 27 + .../topics/data/database/database.g.dart | 930 ++++++++++++++++++ .../data/database/drift/converters/color.dart | 17 + .../data/database/drift/converters/uri.dart | 14 + app/lib/features/topics/data/providers.dart | 37 + app/lib/features/topics/data/providers.g.dart | 24 + app/lib/features/topics/domain/providers.dart | 65 ++ .../features/topics/domain/providers.g.dart | 89 ++ .../topics/domain/repositories/tab.dart | 15 + .../topics/domain/repositories/tab.g.dart | 25 + .../topics/domain/repositories/topic.dart | 52 + .../topics/domain/repositories/topic.g.dart | 26 + .../presentation/screens/topic_list.dart | 203 ++++ .../widgets/material_color_picker.dart | 340 +++++++ .../presentation/widgets/topic_chips.dart | 70 ++ .../presentation/widgets/topic_dialog.dart | 124 +++ .../features/topics/utils/color_palette.dart | 91 ++ .../domain/entities/abstract/tab.dart | 1 + .../domain/entities/web_view_page.dart | 38 +- .../domain/entities/web_view_page.g.dart | 127 --- .../controllers/switch_new_tab.dart | 2 +- .../controllers/switch_new_tab.g.dart | 2 +- .../presentation/widgets/web_page_dialog.dart | 2 +- .../presentation/widgets/web_view.dart | 135 ++- .../widgets/selectable_chips.dart | 12 +- app/pubspec.lock | 18 +- app/pubspec.yaml | 2 +- 35 files changed, 2511 insertions(+), 198 deletions(-) create mode 100644 app/lib/features/topics/data/database/daos/tab.dart create mode 100644 app/lib/features/topics/data/database/daos/tab.g.dart create mode 100644 app/lib/features/topics/data/database/daos/topic.dart create mode 100644 app/lib/features/topics/data/database/daos/topic.g.dart create mode 100644 app/lib/features/topics/data/database/database.dart create mode 100644 app/lib/features/topics/data/database/database.drift create mode 100644 app/lib/features/topics/data/database/database.g.dart create mode 100644 app/lib/features/topics/data/database/drift/converters/color.dart create mode 100644 app/lib/features/topics/data/database/drift/converters/uri.dart create mode 100644 app/lib/features/topics/data/providers.dart create mode 100644 app/lib/features/topics/data/providers.g.dart create mode 100644 app/lib/features/topics/domain/providers.dart create mode 100644 app/lib/features/topics/domain/providers.g.dart create mode 100644 app/lib/features/topics/domain/repositories/tab.dart create mode 100644 app/lib/features/topics/domain/repositories/tab.g.dart create mode 100644 app/lib/features/topics/domain/repositories/topic.dart create mode 100644 app/lib/features/topics/domain/repositories/topic.g.dart create mode 100644 app/lib/features/topics/presentation/screens/topic_list.dart create mode 100644 app/lib/features/topics/presentation/widgets/material_color_picker.dart create mode 100644 app/lib/features/topics/presentation/widgets/topic_chips.dart create mode 100644 app/lib/features/topics/presentation/widgets/topic_dialog.dart create mode 100644 app/lib/features/topics/utils/color_palette.dart delete mode 100644 app/lib/features/web_view/domain/entities/web_view_page.g.dart diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index 3da15717..c006a4f8 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -10,6 +10,7 @@ import 'package:lensai/features/chat_archive/presentation/screens/list.dart'; import 'package:lensai/features/chat_archive/presentation/screens/search.dart'; import 'package:lensai/features/search_browser/presentation/screens/browser.dart'; import 'package:lensai/features/settings/presentation/screens/settings.dart'; +import 'package:lensai/features/topics/presentation/screens/topic_list.dart'; part 'routes.g.dart'; @@ -41,6 +42,10 @@ part 'routes.g.dart'; ), ], ), + TypedGoRoute( + name: 'TopicsRoute', + path: 'topics', + ), ], ) class KagiRoute extends GoRouteData { @@ -97,6 +102,13 @@ class BangSearchRoute extends GoRouteData { } } +class TopicListRoute extends GoRouteData { + @override + Widget build(BuildContext context, GoRouterState state) { + return const TopicListScreen(); + } +} + @TypedGoRoute( name: 'SettingsRoute', path: '/settings', diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index 52fe18e3..4e7a1c44 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -46,6 +46,11 @@ RouteBase get $kagiRoute => GoRouteData.$route( ), ], ), + GoRouteData.$route( + path: 'topics', + name: 'TopicsRoute', + factory: $TopicListRouteExtension._fromState, + ), ], ); @@ -158,6 +163,23 @@ extension $BangSubCategoryRouteExtension on BangSubCategoryRoute { void replace(BuildContext context) => context.replace(location); } +extension $TopicListRouteExtension on TopicListRoute { + static TopicListRoute _fromState(GoRouterState state) => TopicListRoute(); + + String get location => GoRouteData.$location( + '/topics', + ); + + void go(BuildContext context) => context.go(location); + + Future push(BuildContext context) => context.push(location); + + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + void replace(BuildContext context) => context.replace(location); +} + RouteBase get $settingsRoute => GoRouteData.$route( path: '/settings', name: 'SettingsRoute', diff --git a/app/lib/features/search_browser/presentation/widgets/sheets/view_tabs_sheet.dart b/app/lib/features/search_browser/presentation/widgets/sheets/view_tabs_sheet.dart index ce4891b4..ab1a0694 100644 --- a/app/lib/features/search_browser/presentation/widgets/sheets/view_tabs_sheet.dart +++ b/app/lib/features/search_browser/presentation/widgets/sheets/view_tabs_sheet.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/features/topics/presentation/widgets/topic_chips.dart'; import 'package:lensai/features/web_view/domain/entities/abstract/tab.dart'; import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.dart'; @@ -24,28 +25,36 @@ class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate { builder: (context, ref, child) { //Fix layout issue https://github.com/flutter/flutter/issues/78748#issuecomment-1194680555 return Align( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - TextButton.icon( - onPressed: () async { - await ref - .read(switchNewTabControllerProvider.notifier) - .add(Uri.https('kagi.com')); + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + onPressed: () async { + await ref + .read(switchNewTabControllerProvider.notifier) + .add(Uri.https('kagi.com')); - onClose(); - }, - icon: const Icon(Icons.add), - label: const Text('New Tab'), - ), - TextButton.icon( - onPressed: () { - ref.read(webViewRepositoryProvider.notifier).closeAllTabs(); - onClose(); - }, - icon: const Icon(Icons.delete), - label: const Text('Close All'), + onClose(); + }, + icon: const Icon(Icons.add), + label: const Text('New Tab'), + ), + TextButton.icon( + onPressed: () { + ref + .read(webViewRepositoryProvider.notifier) + .closeAllTabs(); + onClose(); + }, + icon: const Icon(Icons.delete), + label: const Text('Close All'), + ), + ], ), + TopicChips(), ], ), ); @@ -55,10 +64,10 @@ class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate { } @override - double get minExtent => 48; + double get minExtent => 96; @override - double get maxExtent => 48; + double get maxExtent => 96; @override bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => diff --git a/app/lib/features/topics/data/database/daos/tab.dart b/app/lib/features/topics/data/database/daos/tab.dart new file mode 100644 index 00000000..64ca0a6a --- /dev/null +++ b/app/lib/features/topics/data/database/daos/tab.dart @@ -0,0 +1,44 @@ +import 'package:drift/drift.dart'; +import 'package:lensai/features/topics/data/database/database.dart'; +import 'package:lensai/features/web_view/domain/entities/abstract/tab.dart'; + +part 'tab.g.dart'; + +@DriftAccessor() +class TabDao extends DatabaseAccessor with _$TabDaoMixin { + TabDao(super.db); + + Future upsertTab(ITab tab) { + return db.tab.insertOne( + TabCompanion.insert( + id: tab.id, + timestamp: DateTime.now(), + url: tab.url, + topicId: Value(tab.topicId), + title: Value(tab.title), + screenshot: Value(tab.screenshot), + ), + mode: InsertMode.insertOrReplace, + ); + } + + Future updateTab( + String id, { + Value url = const Value.absent(), + Value title = const Value.absent(), + Value topicId = const Value.absent(), + Value screenshot = const Value.absent(), + }) { + final statement = db.tab.update()..where((t) => t.id.equals(id)); + + return statement.write( + TabCompanion( + timestamp: Value(DateTime.now()), + url: url, + title: title, + topicId: topicId, + screenshot: screenshot, + ), + ); + } +} diff --git a/app/lib/features/topics/data/database/daos/tab.g.dart b/app/lib/features/topics/data/database/daos/tab.g.dart new file mode 100644 index 00000000..3da23453 --- /dev/null +++ b/app/lib/features/topics/data/database/daos/tab.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tab.dart'; + +// ignore_for_file: type=lint +mixin _$TabDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/topics/data/database/daos/topic.dart b/app/lib/features/topics/data/database/daos/topic.dart new file mode 100644 index 00000000..d0e66d55 --- /dev/null +++ b/app/lib/features/topics/data/database/daos/topic.dart @@ -0,0 +1,53 @@ +import 'dart:ui'; + +import 'package:drift/drift.dart'; +import 'package:lensai/core/uuid.dart'; +import 'package:lensai/features/topics/data/database/database.dart'; + +part 'topic.g.dart'; + +@DriftAccessor() +class TopicDao extends DatabaseAccessor with _$TopicDaoMixin { + TopicDao(super.db); + + Future addTopic({String? name, required Color color}) { + return db.topic.insertOne( + TopicCompanion.insert( + id: uuid.v7(), + name: Value(name), + color: color, + ), + ); + } + + Future replaceTopic( + String id, { + required String? name, + required Color color, + }) { + return db.topic.replaceOne( + TopicCompanion( + id: Value(id), + name: Value(name), + color: Value(color), + ), + ); + } + + Future deleteTopic(String id) { + return db.topic.deleteOne(TopicCompanion.custom(id: Variable(id))); + } + + SingleOrNullSelectable getTopicData(String id) { + return select(db.topic)..where((t) => t.id.equals(id)); + } + + Selectable getDistinctColors() { + final query = db.selectOnly(db.topic, distinct: true) + ..addColumns([db.topic.color]) + ..where(db.topic.color.isNotNull()); + + return query + .map((row) => row.readWithConverter(db.topic.color)!); + } +} diff --git a/app/lib/features/topics/data/database/daos/topic.g.dart b/app/lib/features/topics/data/database/daos/topic.g.dart new file mode 100644 index 00000000..30a65165 --- /dev/null +++ b/app/lib/features/topics/data/database/daos/topic.g.dart @@ -0,0 +1,6 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'topic.dart'; + +// ignore_for_file: type=lint +mixin _$TopicDaoMixin on DatabaseAccessor {} diff --git a/app/lib/features/topics/data/database/database.dart b/app/lib/features/topics/data/database/database.dart new file mode 100644 index 00000000..45a7281f --- /dev/null +++ b/app/lib/features/topics/data/database/database.dart @@ -0,0 +1,27 @@ +import 'dart:ui'; + +import 'package:drift/drift.dart'; +import 'package:lensai/features/topics/data/database/daos/tab.dart'; +import 'package:lensai/features/topics/data/database/daos/topic.dart'; +import 'package:lensai/features/topics/data/database/drift/converters/color.dart'; +import 'package:lensai/features/topics/data/database/drift/converters/uri.dart'; + +part 'database.g.dart'; + +@DriftDatabase( + include: {'database.drift'}, + daos: [TopicDao, TabDao], +) +class TabDatabase extends _$TabDatabase { + @override + final int schemaVersion = 1; + + @override + MigrationStrategy get migration => MigrationStrategy( + beforeOpen: (details) async { + await customStatement('PRAGMA foreign_keys = ON;'); + }, + ); + + TabDatabase(super.e); +} diff --git a/app/lib/features/topics/data/database/database.drift b/app/lib/features/topics/data/database/database.drift new file mode 100644 index 00000000..518bc3aa --- /dev/null +++ b/app/lib/features/topics/data/database/database.drift @@ -0,0 +1,27 @@ +import 'package:lensai/features/topics/data/database/drift/converters/color.dart'; +import 'package:lensai/features/topics/data/database/drift/converters/uri.dart'; + +CREATE TABLE topic ( + id TEXT PRIMARY KEY NOT NULL, + name TEXT, + color INTEGER NOT NULL MAPPED BY `const ColorConverter()` +); + +CREATE TABLE tab ( + id TEXT PRIMARY KEY NOT NULL, + topic_id TEXT REFERENCES topic (id) ON DELETE CASCADE, + timestamp DATETIME NOT NULL, + url TEXT NOT NULL MAPPED BY `const UriConverter()`, + title TEXT, + screenshot BLOB +); + +topics: + SELECT topic.* + FROM topic + LEFT JOIN ( + SELECT topic_id, MAX(timestamp) AS last_updated + FROM tab + GROUP BY topic_id + ) AS tab_max ON topic.id = tab_max.topic_id + ORDER BY tab_max.last_updated DESC NULLS FIRST; diff --git a/app/lib/features/topics/data/database/database.g.dart b/app/lib/features/topics/data/database/database.g.dart new file mode 100644 index 00000000..a922881f --- /dev/null +++ b/app/lib/features/topics/data/database/database.g.dart @@ -0,0 +1,930 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'database.dart'; + +// ignore_for_file: type=lint +class Topic extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Topic(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL'); + late final GeneratedColumn name = GeneratedColumn( + 'name', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: ''); + late final GeneratedColumnWithTypeConverter color = + GeneratedColumn('color', aliasedName, false, + type: DriftSqlType.int, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL') + .withConverter(Topic.$convertercolor); + @override + List get $columns => [id, name, color]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'topic'; + @override + Set get $primaryKey => {id}; + @override + TopicData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TopicData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + name: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}name']), + color: Topic.$convertercolor.fromSql(attachedDatabase.typeMapping + .read(DriftSqlType.int, data['${effectivePrefix}color'])!), + ); + } + + @override + Topic createAlias(String alias) { + return Topic(attachedDatabase, alias); + } + + static TypeConverter $convertercolor = const ColorConverter(); + @override + bool get dontWriteConstraints => true; +} + +class TopicData extends DataClass implements Insertable { + final String id; + final String? name; + final Color color; + const TopicData({required this.id, this.name, required this.color}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || name != null) { + map['name'] = Variable(name); + } + { + map['color'] = Variable(Topic.$convertercolor.toSql(color)); + } + return map; + } + + factory TopicData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TopicData( + id: serializer.fromJson(json['id']), + name: serializer.fromJson(json['name']), + color: serializer.fromJson(json['color']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'name': serializer.toJson(name), + 'color': serializer.toJson(color), + }; + } + + TopicData copyWith( + {String? id, + Value name = const Value.absent(), + Color? color}) => + TopicData( + id: id ?? this.id, + name: name.present ? name.value : this.name, + color: color ?? this.color, + ); + TopicData copyWithCompanion(TopicCompanion data) { + return TopicData( + id: data.id.present ? data.id.value : this.id, + name: data.name.present ? data.name.value : this.name, + color: data.color.present ? data.color.value : this.color, + ); + } + + @override + String toString() { + return (StringBuffer('TopicData(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash(id, name, color); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TopicData && + other.id == this.id && + other.name == this.name && + other.color == this.color); +} + +class TopicCompanion extends UpdateCompanion { + final Value id; + final Value name; + final Value color; + final Value rowid; + const TopicCompanion({ + this.id = const Value.absent(), + this.name = const Value.absent(), + this.color = const Value.absent(), + this.rowid = const Value.absent(), + }); + TopicCompanion.insert({ + required String id, + this.name = const Value.absent(), + required Color color, + this.rowid = const Value.absent(), + }) : id = Value(id), + color = Value(color); + static Insertable custom({ + Expression? id, + Expression? name, + Expression? color, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (name != null) 'name': name, + if (color != null) 'color': color, + if (rowid != null) 'rowid': rowid, + }); + } + + TopicCompanion copyWith( + {Value? id, + Value? name, + Value? color, + Value? rowid}) { + return TopicCompanion( + id: id ?? this.id, + name: name ?? this.name, + color: color ?? this.color, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (name.present) { + map['name'] = Variable(name.value); + } + if (color.present) { + map['color'] = Variable(Topic.$convertercolor.toSql(color.value)); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TopicCompanion(') + ..write('id: $id, ') + ..write('name: $name, ') + ..write('color: $color, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class Tab extends Table with TableInfo { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + Tab(this.attachedDatabase, [this._alias]); + late final GeneratedColumn id = GeneratedColumn( + 'id', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'PRIMARY KEY NOT NULL'); + late final GeneratedColumn topicId = GeneratedColumn( + 'topic_id', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: 'REFERENCES topic(id)ON DELETE CASCADE'); + late final GeneratedColumn timestamp = GeneratedColumn( + 'timestamp', aliasedName, false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL'); + late final GeneratedColumnWithTypeConverter url = + GeneratedColumn('url', aliasedName, false, + type: DriftSqlType.string, + requiredDuringInsert: true, + $customConstraints: 'NOT NULL') + .withConverter(Tab.$converterurl); + late final GeneratedColumn title = GeneratedColumn( + 'title', aliasedName, true, + type: DriftSqlType.string, + requiredDuringInsert: false, + $customConstraints: ''); + late final GeneratedColumn screenshot = GeneratedColumn( + 'screenshot', aliasedName, true, + type: DriftSqlType.blob, + requiredDuringInsert: false, + $customConstraints: ''); + @override + List get $columns => + [id, topicId, timestamp, url, title, screenshot]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'tab'; + @override + Set get $primaryKey => {id}; + @override + TabData map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return TabData( + id: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}id'])!, + topicId: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}topic_id']), + timestamp: attachedDatabase.typeMapping + .read(DriftSqlType.dateTime, data['${effectivePrefix}timestamp'])!, + url: Tab.$converterurl.fromSql(attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}url'])!), + title: attachedDatabase.typeMapping + .read(DriftSqlType.string, data['${effectivePrefix}title']), + screenshot: attachedDatabase.typeMapping + .read(DriftSqlType.blob, data['${effectivePrefix}screenshot']), + ); + } + + @override + Tab createAlias(String alias) { + return Tab(attachedDatabase, alias); + } + + static TypeConverter $converterurl = const UriConverter(); + @override + bool get dontWriteConstraints => true; +} + +class TabData extends DataClass implements Insertable { + final String id; + final String? topicId; + final DateTime timestamp; + final Uri url; + final String? title; + final Uint8List? screenshot; + const TabData( + {required this.id, + this.topicId, + required this.timestamp, + required this.url, + this.title, + this.screenshot}); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || topicId != null) { + map['topic_id'] = Variable(topicId); + } + map['timestamp'] = Variable(timestamp); + { + map['url'] = Variable(Tab.$converterurl.toSql(url)); + } + if (!nullToAbsent || title != null) { + map['title'] = Variable(title); + } + if (!nullToAbsent || screenshot != null) { + map['screenshot'] = Variable(screenshot); + } + return map; + } + + factory TabData.fromJson(Map json, + {ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return TabData( + id: serializer.fromJson(json['id']), + topicId: serializer.fromJson(json['topic_id']), + timestamp: serializer.fromJson(json['timestamp']), + url: serializer.fromJson(json['url']), + title: serializer.fromJson(json['title']), + screenshot: serializer.fromJson(json['screenshot']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'topic_id': serializer.toJson(topicId), + 'timestamp': serializer.toJson(timestamp), + 'url': serializer.toJson(url), + 'title': serializer.toJson(title), + 'screenshot': serializer.toJson(screenshot), + }; + } + + TabData copyWith( + {String? id, + Value topicId = const Value.absent(), + DateTime? timestamp, + Uri? url, + Value title = const Value.absent(), + Value screenshot = const Value.absent()}) => + TabData( + id: id ?? this.id, + topicId: topicId.present ? topicId.value : this.topicId, + timestamp: timestamp ?? this.timestamp, + url: url ?? this.url, + title: title.present ? title.value : this.title, + screenshot: screenshot.present ? screenshot.value : this.screenshot, + ); + TabData copyWithCompanion(TabCompanion data) { + return TabData( + id: data.id.present ? data.id.value : this.id, + topicId: data.topicId.present ? data.topicId.value : this.topicId, + timestamp: data.timestamp.present ? data.timestamp.value : this.timestamp, + url: data.url.present ? data.url.value : this.url, + title: data.title.present ? data.title.value : this.title, + screenshot: + data.screenshot.present ? data.screenshot.value : this.screenshot, + ); + } + + @override + String toString() { + return (StringBuffer('TabData(') + ..write('id: $id, ') + ..write('topicId: $topicId, ') + ..write('timestamp: $timestamp, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('screenshot: $screenshot') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, topicId, timestamp, url, title, $driftBlobEquality.hash(screenshot)); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is TabData && + other.id == this.id && + other.topicId == this.topicId && + other.timestamp == this.timestamp && + other.url == this.url && + other.title == this.title && + $driftBlobEquality.equals(other.screenshot, this.screenshot)); +} + +class TabCompanion extends UpdateCompanion { + final Value id; + final Value topicId; + final Value timestamp; + final Value url; + final Value title; + final Value screenshot; + final Value rowid; + const TabCompanion({ + this.id = const Value.absent(), + this.topicId = const Value.absent(), + this.timestamp = const Value.absent(), + this.url = const Value.absent(), + this.title = const Value.absent(), + this.screenshot = const Value.absent(), + this.rowid = const Value.absent(), + }); + TabCompanion.insert({ + required String id, + this.topicId = const Value.absent(), + required DateTime timestamp, + required Uri url, + this.title = const Value.absent(), + this.screenshot = const Value.absent(), + this.rowid = const Value.absent(), + }) : id = Value(id), + timestamp = Value(timestamp), + url = Value(url); + static Insertable custom({ + Expression? id, + Expression? topicId, + Expression? timestamp, + Expression? url, + Expression? title, + Expression? screenshot, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (topicId != null) 'topic_id': topicId, + if (timestamp != null) 'timestamp': timestamp, + if (url != null) 'url': url, + if (title != null) 'title': title, + if (screenshot != null) 'screenshot': screenshot, + if (rowid != null) 'rowid': rowid, + }); + } + + TabCompanion copyWith( + {Value? id, + Value? topicId, + Value? timestamp, + Value? url, + Value? title, + Value? screenshot, + Value? rowid}) { + return TabCompanion( + id: id ?? this.id, + topicId: topicId ?? this.topicId, + timestamp: timestamp ?? this.timestamp, + url: url ?? this.url, + title: title ?? this.title, + screenshot: screenshot ?? this.screenshot, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (topicId.present) { + map['topic_id'] = Variable(topicId.value); + } + if (timestamp.present) { + map['timestamp'] = Variable(timestamp.value); + } + if (url.present) { + map['url'] = Variable(Tab.$converterurl.toSql(url.value)); + } + if (title.present) { + map['title'] = Variable(title.value); + } + if (screenshot.present) { + map['screenshot'] = Variable(screenshot.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('TabCompanion(') + ..write('id: $id, ') + ..write('topicId: $topicId, ') + ..write('timestamp: $timestamp, ') + ..write('url: $url, ') + ..write('title: $title, ') + ..write('screenshot: $screenshot, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$TabDatabase extends GeneratedDatabase { + _$TabDatabase(QueryExecutor e) : super(e); + $TabDatabaseManager get managers => $TabDatabaseManager(this); + late final Topic topic = Topic(this); + late final Tab tab = Tab(this); + late final TopicDao topicDao = TopicDao(this as TabDatabase); + late final TabDao tabDao = TabDao(this as TabDatabase); + Selectable topics() { + return customSelect( + 'SELECT topic.* FROM topic LEFT JOIN (SELECT topic_id, MAX(timestamp) AS last_updated FROM tab GROUP BY topic_id) AS tab_max ON topic.id = tab_max.topic_id ORDER BY tab_max.last_updated DESC NULLS FIRST', + variables: [], + readsFrom: { + topic, + tab, + }).asyncMap(topic.mapFromRow); + } + + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [topic, tab]; + @override + StreamQueryUpdateRules get streamUpdateRules => const StreamQueryUpdateRules( + [ + WritePropagation( + on: TableUpdateQuery.onTableName('topic', + limitUpdateKind: UpdateKind.delete), + result: [ + TableUpdate('tab', kind: UpdateKind.delete), + ], + ), + ], + ); +} + +typedef $TopicCreateCompanionBuilder = TopicCompanion Function({ + required String id, + Value name, + required Color color, + Value rowid, +}); +typedef $TopicUpdateCompanionBuilder = TopicCompanion Function({ + Value id, + Value name, + Value color, + Value rowid, +}); + +final class $TopicReferences + extends BaseReferences<_$TabDatabase, Topic, TopicData> { + $TopicReferences(super.$_db, super.$_table, super.$_typedResult); + + static MultiTypedResultKey> _tabRefsTable( + _$TabDatabase db) => + MultiTypedResultKey.fromTable(db.tab, + aliasName: $_aliasNameGenerator(db.topic.id, db.tab.topicId)); + + $TabProcessedTableManager get tabRefs { + final manager = + $TabTableManager($_db, $_db.tab).filter((f) => f.topicId.id($_item.id)); + + final cache = $_typedResult.readTableOrNull(_tabRefsTable($_db)); + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: cache)); + } +} + +class $TopicFilterComposer extends FilterComposer<_$TabDatabase, Topic> { + $TopicFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get name => $state.composableBuilder( + column: $state.table.name, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters get color => + $state.composableBuilder( + column: $state.table.color, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ComposableFilter tabRefs(ComposableFilter Function($TabFilterComposer f) f) { + final $TabFilterComposer composer = $state.composerBuilder( + composer: this, + getCurrentColumn: (t) => t.id, + referencedTable: $state.db.tab, + getReferencedColumn: (t) => t.topicId, + builder: (joinBuilder, parentComposers) => $TabFilterComposer( + ComposerState( + $state.db, $state.db.tab, joinBuilder, parentComposers))); + return f(composer); + } +} + +class $TopicOrderingComposer extends OrderingComposer<_$TabDatabase, Topic> { + $TopicOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get name => $state.composableBuilder( + column: $state.table.name, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get color => $state.composableBuilder( + column: $state.table.color, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); +} + +class $TopicTableManager extends RootTableManager< + _$TabDatabase, + Topic, + TopicData, + $TopicFilterComposer, + $TopicOrderingComposer, + $TopicCreateCompanionBuilder, + $TopicUpdateCompanionBuilder, + (TopicData, $TopicReferences), + TopicData, + PrefetchHooks Function({bool tabRefs})> { + $TopicTableManager(_$TabDatabase db, Topic table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: $TopicFilterComposer(ComposerState(db, table)), + orderingComposer: $TopicOrderingComposer(ComposerState(db, table)), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value name = const Value.absent(), + Value color = const Value.absent(), + Value rowid = const Value.absent(), + }) => + TopicCompanion( + id: id, + name: name, + color: color, + rowid: rowid, + ), + createCompanionCallback: ({ + required String id, + Value name = const Value.absent(), + required Color color, + Value rowid = const Value.absent(), + }) => + TopicCompanion.insert( + id: id, + name: name, + color: color, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), $TopicReferences(db, table, e))) + .toList(), + prefetchHooksCallback: ({tabRefs = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [if (tabRefs) db.tab], + addJoins: null, + getPrefetchedDataCallback: (items) async { + return [ + if (tabRefs) + await $_getPrefetchedData( + currentTable: table, + referencedTable: $TopicReferences._tabRefsTable(db), + managerFromTypedResult: (p0) => + $TopicReferences(db, table, p0).tabRefs, + referencedItemsForCurrentItem: (item, + referencedItems) => + referencedItems.where((e) => e.topicId == item.id), + typedResults: items) + ]; + }, + ); + }, + )); +} + +typedef $TopicProcessedTableManager = ProcessedTableManager< + _$TabDatabase, + Topic, + TopicData, + $TopicFilterComposer, + $TopicOrderingComposer, + $TopicCreateCompanionBuilder, + $TopicUpdateCompanionBuilder, + (TopicData, $TopicReferences), + TopicData, + PrefetchHooks Function({bool tabRefs})>; +typedef $TabCreateCompanionBuilder = TabCompanion Function({ + required String id, + Value topicId, + required DateTime timestamp, + required Uri url, + Value title, + Value screenshot, + Value rowid, +}); +typedef $TabUpdateCompanionBuilder = TabCompanion Function({ + Value id, + Value topicId, + Value timestamp, + Value url, + Value title, + Value screenshot, + Value rowid, +}); + +final class $TabReferences extends BaseReferences<_$TabDatabase, Tab, TabData> { + $TabReferences(super.$_db, super.$_table, super.$_typedResult); + + static Topic _topicIdTable(_$TabDatabase db) => + db.topic.createAlias($_aliasNameGenerator(db.tab.topicId, db.topic.id)); + + $TopicProcessedTableManager? get topicId { + if ($_item.topicId == null) return null; + final manager = $TopicTableManager($_db, $_db.topic) + .filter((f) => f.id($_item.topicId!)); + final item = $_typedResult.readTableOrNull(_topicIdTable($_db)); + if (item == null) return manager; + return ProcessedTableManager( + manager.$state.copyWith(prefetchedData: [item])); + } +} + +class $TabFilterComposer extends FilterComposer<_$TabDatabase, Tab> { + $TabFilterComposer(super.$state); + ColumnFilters get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get timestamp => $state.composableBuilder( + column: $state.table.timestamp, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnWithTypeConverterFilters get url => + $state.composableBuilder( + column: $state.table.url, + builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( + column, + joinBuilders: joinBuilders)); + + ColumnFilters get title => $state.composableBuilder( + column: $state.table.title, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + ColumnFilters get screenshot => $state.composableBuilder( + column: $state.table.screenshot, + builder: (column, joinBuilders) => + ColumnFilters(column, joinBuilders: joinBuilders)); + + $TopicFilterComposer get topicId { + final $TopicFilterComposer composer = $state.composerBuilder( + composer: this, + getCurrentColumn: (t) => t.topicId, + referencedTable: $state.db.topic, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, parentComposers) => $TopicFilterComposer( + ComposerState( + $state.db, $state.db.topic, joinBuilder, parentComposers))); + return composer; + } +} + +class $TabOrderingComposer extends OrderingComposer<_$TabDatabase, Tab> { + $TabOrderingComposer(super.$state); + ColumnOrderings get id => $state.composableBuilder( + column: $state.table.id, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get timestamp => $state.composableBuilder( + column: $state.table.timestamp, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get url => $state.composableBuilder( + column: $state.table.url, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get title => $state.composableBuilder( + column: $state.table.title, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + ColumnOrderings get screenshot => $state.composableBuilder( + column: $state.table.screenshot, + builder: (column, joinBuilders) => + ColumnOrderings(column, joinBuilders: joinBuilders)); + + $TopicOrderingComposer get topicId { + final $TopicOrderingComposer composer = $state.composerBuilder( + composer: this, + getCurrentColumn: (t) => t.topicId, + referencedTable: $state.db.topic, + getReferencedColumn: (t) => t.id, + builder: (joinBuilder, parentComposers) => $TopicOrderingComposer( + ComposerState( + $state.db, $state.db.topic, joinBuilder, parentComposers))); + return composer; + } +} + +class $TabTableManager extends RootTableManager< + _$TabDatabase, + Tab, + TabData, + $TabFilterComposer, + $TabOrderingComposer, + $TabCreateCompanionBuilder, + $TabUpdateCompanionBuilder, + (TabData, $TabReferences), + TabData, + PrefetchHooks Function({bool topicId})> { + $TabTableManager(_$TabDatabase db, Tab table) + : super(TableManagerState( + db: db, + table: table, + filteringComposer: $TabFilterComposer(ComposerState(db, table)), + orderingComposer: $TabOrderingComposer(ComposerState(db, table)), + updateCompanionCallback: ({ + Value id = const Value.absent(), + Value topicId = const Value.absent(), + Value timestamp = const Value.absent(), + Value url = const Value.absent(), + Value title = const Value.absent(), + Value screenshot = const Value.absent(), + Value rowid = const Value.absent(), + }) => + TabCompanion( + id: id, + topicId: topicId, + timestamp: timestamp, + url: url, + title: title, + screenshot: screenshot, + rowid: rowid, + ), + createCompanionCallback: ({ + required String id, + Value topicId = const Value.absent(), + required DateTime timestamp, + required Uri url, + Value title = const Value.absent(), + Value screenshot = const Value.absent(), + Value rowid = const Value.absent(), + }) => + TabCompanion.insert( + id: id, + topicId: topicId, + timestamp: timestamp, + url: url, + title: title, + screenshot: screenshot, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), $TabReferences(db, table, e))) + .toList(), + prefetchHooksCallback: ({topicId = false}) { + return PrefetchHooks( + db: db, + explicitlyWatchedTables: [], + addJoins: < + T extends TableManagerState< + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic, + dynamic>>(state) { + if (topicId) { + state = state.withJoin( + currentTable: table, + currentColumn: table.topicId, + referencedTable: $TabReferences._topicIdTable(db), + referencedColumn: $TabReferences._topicIdTable(db).id, + ) as T; + } + + return state; + }, + getPrefetchedDataCallback: (items) async { + return []; + }, + ); + }, + )); +} + +typedef $TabProcessedTableManager = ProcessedTableManager< + _$TabDatabase, + Tab, + TabData, + $TabFilterComposer, + $TabOrderingComposer, + $TabCreateCompanionBuilder, + $TabUpdateCompanionBuilder, + (TabData, $TabReferences), + TabData, + PrefetchHooks Function({bool topicId})>; + +class $TabDatabaseManager { + final _$TabDatabase _db; + $TabDatabaseManager(this._db); + $TopicTableManager get topic => $TopicTableManager(_db, _db.topic); + $TabTableManager get tab => $TabTableManager(_db, _db.tab); +} diff --git a/app/lib/features/topics/data/database/drift/converters/color.dart b/app/lib/features/topics/data/database/drift/converters/color.dart new file mode 100644 index 00000000..74a2df06 --- /dev/null +++ b/app/lib/features/topics/data/database/drift/converters/color.dart @@ -0,0 +1,17 @@ +import 'dart:ui'; + +import 'package:drift/drift.dart'; + +class ColorConverter extends TypeConverter { + const ColorConverter(); + + @override + Color fromSql(int fromDb) { + return Color(fromDb); + } + + @override + int toSql(Color value) { + return value.value; + } +} diff --git a/app/lib/features/topics/data/database/drift/converters/uri.dart b/app/lib/features/topics/data/database/drift/converters/uri.dart new file mode 100644 index 00000000..1f028909 --- /dev/null +++ b/app/lib/features/topics/data/database/drift/converters/uri.dart @@ -0,0 +1,14 @@ +import 'package:drift/drift.dart'; +import 'package:lensai/utils/uri_parser.dart' as uri_parser; + +class UriConverter extends TypeConverter { + @override + Uri fromSql(String fromDb) { + return uri_parser.tryParseUrl(fromDb, eagerParsing: true)!; + } + + @override + String toSql(Uri value) { + return value.toString(); + } +} diff --git a/app/lib/features/topics/data/providers.dart b/app/lib/features/topics/data/providers.dart new file mode 100644 index 00000000..b9f6cc4c --- /dev/null +++ b/app/lib/features/topics/data/providers.dart @@ -0,0 +1,37 @@ +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:lensai/features/topics/data/database/database.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart' as path_provider; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:sqlite3/sqlite3.dart'; +import 'package:sqlite3_flutter_libs/sqlite3_flutter_libs.dart'; +import 'package:universal_io/io.dart'; + +part 'providers.g.dart'; + +@Riverpod(keepAlive: true) +TabDatabase tabDatabase(TabDatabaseRef ref) { + return TabDatabase( + LazyDatabase(() async { + // put the database file, called db.sqlite here, into the documents folder + // for your app. + final dbFolder = await path_provider.getApplicationDocumentsDirectory(); + final file = File(p.join(dbFolder.path, 'tab.db')); + + // Also work around limitations on old Android versions + if (Platform.isAndroid) { + await applyWorkaroundToOpenSqlite3OnOldAndroidVersions(); + } + + // Make sqlite3 pick a more suitable location for temporary files - the + // one from the system may be inaccessible due to sandboxing. + final cachebase = (await path_provider.getTemporaryDirectory()).path; + // We can't access /tmp on Android, which sqlite3 would try by default. + // Explicitly tell it about the correct temporary directory. + sqlite3.tempDirectory = cachebase; + + return NativeDatabase.createInBackground(file); + }), + ); +} diff --git a/app/lib/features/topics/data/providers.g.dart b/app/lib/features/topics/data/providers.g.dart new file mode 100644 index 00000000..13b43baa --- /dev/null +++ b/app/lib/features/topics/data/providers.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$tabDatabaseHash() => r'b886b089ae5fdb9bdcc0982efe5fcbe9bc66486f'; + +/// See also [tabDatabase]. +@ProviderFor(tabDatabase) +final tabDatabaseProvider = Provider.internal( + tabDatabase, + name: r'tabDatabaseProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$tabDatabaseHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef TabDatabaseRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/topics/domain/providers.dart b/app/lib/features/topics/domain/providers.dart new file mode 100644 index 00000000..a4022831 --- /dev/null +++ b/app/lib/features/topics/domain/providers.dart @@ -0,0 +1,65 @@ +import 'dart:ui'; + +import 'package:collection/collection.dart'; +import 'package:lensai/features/topics/data/database/database.dart'; +import 'package:lensai/features/topics/domain/repositories/topic.dart'; +import 'package:lensai/features/topics/utils/color_palette.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'providers.g.dart'; + +@Riverpod() +Stream> topicList(TopicListRef ref) { + final repository = ref.watch(topicRepositoryProvider.notifier); + return repository.watchTopics(); +} + +@Riverpod(keepAlive: true) +class SelectedTopic extends _$SelectedTopic { + void setTopic(String id) { + state = id; + } + + void toggleTopic(String id) { + if (state == id) { + clearTopic(); + } else { + setTopic(id); + } + } + + void clearTopic() { + state = null; + } + + @override + String? build() { + return null; + } +} + +@Riverpod() +Stream selectedTopicData(SelectedTopicDataRef ref) { + final repository = ref.watch(topicRepositoryProvider.notifier); + final selectedBangTrigger = ref.watch(selectedTopicProvider); + return repository.watchTopic(selectedBangTrigger); +} + +@Riverpod() +Future> distinctTopicColors(DistinctTopicColorsRef ref) { + final repository = ref.watch(topicRepositoryProvider.notifier); + return repository.getDistinctColors(); +} + +@Riverpod() +Future unusedRandomTopicColor(UnusedRandomTopicColorRef ref) async { + final allColors = colorTypes.flattened.toList(); + final usedColors = await ref.read(distinctTopicColorsProvider.future); + + Color randomColor; + do { + randomColor = randomColorShade(allColors); + } while (usedColors.contains(randomColor)); + + return randomColor; +} diff --git a/app/lib/features/topics/domain/providers.g.dart b/app/lib/features/topics/domain/providers.g.dart new file mode 100644 index 00000000..b35e19df --- /dev/null +++ b/app/lib/features/topics/domain/providers.g.dart @@ -0,0 +1,89 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'providers.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$topicListHash() => r'7220aef5653bb3c2b44b016ce4f083f20896b353'; + +/// See also [topicList]. +@ProviderFor(topicList) +final topicListProvider = AutoDisposeStreamProvider>.internal( + topicList, + name: r'topicListProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$topicListHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef TopicListRef = AutoDisposeStreamProviderRef>; +String _$selectedTopicDataHash() => r'191ad088ab90ef2348a836593f92b48d095afdc8'; + +/// See also [selectedTopicData]. +@ProviderFor(selectedTopicData) +final selectedTopicDataProvider = + AutoDisposeStreamProvider.internal( + selectedTopicData, + name: r'selectedTopicDataProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$selectedTopicDataHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef SelectedTopicDataRef = AutoDisposeStreamProviderRef; +String _$distinctTopicColorsHash() => + r'c671f349615313d3b17e93e9e46fae12889e5e1f'; + +/// See also [distinctTopicColors]. +@ProviderFor(distinctTopicColors) +final distinctTopicColorsProvider = + AutoDisposeFutureProvider>.internal( + distinctTopicColors, + name: r'distinctTopicColorsProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$distinctTopicColorsHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef DistinctTopicColorsRef = AutoDisposeFutureProviderRef>; +String _$unusedRandomTopicColorHash() => + r'8f6907bb50a5bf2ac0320e93531d9e61616337b9'; + +/// See also [unusedRandomTopicColor]. +@ProviderFor(unusedRandomTopicColor) +final unusedRandomTopicColorProvider = + AutoDisposeFutureProvider.internal( + unusedRandomTopicColor, + name: r'unusedRandomTopicColorProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$unusedRandomTopicColorHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef UnusedRandomTopicColorRef = AutoDisposeFutureProviderRef; +String _$selectedTopicHash() => r'f4e1c0620971a0b7501ee9aff0d5b41da3130ba0'; + +/// See also [SelectedTopic]. +@ProviderFor(SelectedTopic) +final selectedTopicProvider = NotifierProvider.internal( + SelectedTopic.new, + name: r'selectedTopicProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$selectedTopicHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$SelectedTopic = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/topics/domain/repositories/tab.dart b/app/lib/features/topics/domain/repositories/tab.dart new file mode 100644 index 00000000..141a18bb --- /dev/null +++ b/app/lib/features/topics/domain/repositories/tab.dart @@ -0,0 +1,15 @@ +import 'package:lensai/features/topics/data/database/database.dart'; +import 'package:lensai/features/topics/data/providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'tab.g.dart'; + +@Riverpod(keepAlive: true) +class TabRepository extends _$TabRepository { + late TabDatabase _db; + + @override + void build() { + _db = ref.watch(tabDatabaseProvider); + } +} diff --git a/app/lib/features/topics/domain/repositories/tab.g.dart b/app/lib/features/topics/domain/repositories/tab.g.dart new file mode 100644 index 00000000..e45e343f --- /dev/null +++ b/app/lib/features/topics/domain/repositories/tab.g.dart @@ -0,0 +1,25 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'tab.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$tabRepositoryHash() => r'3f564f553e9586df34e927b20fe4bc860e0814e9'; + +/// See also [TabRepository]. +@ProviderFor(TabRepository) +final tabRepositoryProvider = NotifierProvider.internal( + TabRepository.new, + name: r'tabRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$tabRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$TabRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/topics/domain/repositories/topic.dart b/app/lib/features/topics/domain/repositories/topic.dart new file mode 100644 index 00000000..51f8ef46 --- /dev/null +++ b/app/lib/features/topics/domain/repositories/topic.dart @@ -0,0 +1,52 @@ +import 'dart:ui'; + +import 'package:lensai/features/topics/data/database/database.dart'; +import 'package:lensai/features/topics/data/providers.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'topic.g.dart'; + +@Riverpod(keepAlive: true) +class TopicRepository extends _$TopicRepository { + late TabDatabase _db; + + @override + void build() { + _db = ref.watch(tabDatabaseProvider); + } + + Future addTopic({required String? name, required Color color}) { + return _db.topicDao.addTopic(name: name, color: color); + } + + Future replaceTopic({ + required String id, + required String? name, + required Color color, + }) { + return _db.topicDao.replaceTopic(id, name: name, color: color); + } + + Future deleteTopic(String id) { + return _db.topicDao.deleteTopic(id); + } + + Stream> watchTopics() { + return _db.topics().watch(); + } + + Stream watchTopic(String? id) { + if (id != null) { + return _db.topicDao.getTopicData(id).watchSingleOrNull(); + } else { + return Stream.value(null); + } + } + + Future> getDistinctColors() { + return _db.topicDao + .getDistinctColors() + .get() + .then((colors) => colors.toSet()); + } +} diff --git a/app/lib/features/topics/domain/repositories/topic.g.dart b/app/lib/features/topics/domain/repositories/topic.g.dart new file mode 100644 index 00000000..f7d9fe46 --- /dev/null +++ b/app/lib/features/topics/domain/repositories/topic.g.dart @@ -0,0 +1,26 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'topic.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$topicRepositoryHash() => r'f0e8d3170d3706a79decfbe4700f20fcdebfae2c'; + +/// See also [TopicRepository]. +@ProviderFor(TopicRepository) +final topicRepositoryProvider = + NotifierProvider.internal( + TopicRepository.new, + name: r'topicRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$topicRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$TopicRepository = Notifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member diff --git a/app/lib/features/topics/presentation/screens/topic_list.dart b/app/lib/features/topics/presentation/screens/topic_list.dart new file mode 100644 index 00000000..c0cb4097 --- /dev/null +++ b/app/lib/features/topics/presentation/screens/topic_list.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/features/topics/data/database/database.dart'; +import 'package:lensai/features/topics/domain/providers.dart'; +import 'package:lensai/features/topics/domain/repositories/topic.dart'; +import 'package:lensai/features/topics/presentation/widgets/topic_dialog.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +class _TopicTile extends HookWidget { + final TopicData topic; + final bool isSelected; + + final void Function(TopicResult edited) onEdit; + final void Function() onDelete; + final void Function() onTap; + + const _TopicTile( + this.topic, { + required this.isSelected, + required this.onEdit, + required this.onDelete, + required this.onTap, + super.key, + }); + + @override + Widget build(BuildContext context) { + final menuController = useMemoized(() => MenuController()); + + return ListTile( + selected: isSelected, + leading: CircleAvatar(backgroundColor: topic.color), + title: Text(topic.name ?? 'New Topic'), + trailing: MenuAnchor( + controller: menuController, + builder: (context, controller, child) { + return Padding( + padding: const EdgeInsets.only(right: 4.0), + child: InkWell( + onTap: () { + if (controller.isOpen) { + controller.close(); + } else { + controller.open(); + } + }, + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 15.0, horizontal: 8.0), + child: Icon(Icons.more_vert), + ), + ), + ); + }, + menuChildren: [ + MenuItemButton( + onPressed: () async { + final result = await showDialog( + context: context, + builder: (context) => TopicDialog.edit( + name: topic.name, + initialColor: topic.color, + ), + ); + + if (result != null) { + onEdit(result); + } + }, + leadingIcon: const Icon(Icons.edit), + child: const Text('Edit'), + ), + MenuItemButton( + onPressed: () async { + final result = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Delete Topic'), + content: const Text( + 'Are you sure you want to delete this topic and all attached tabs?', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text('Delete'), + ), + ], + ); + }, + ); + + if (result == true) { + onDelete(); + } + }, + leadingIcon: const Icon(Icons.delete), + child: const Text('Delete'), + ), + ], + ), + onTap: onTap, + ); + } +} + +class TopicListScreen extends HookConsumerWidget { + const TopicListScreen(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar( + title: const Text('Topics'), + actions: [ + IconButton( + onPressed: () async { + final initialColor = + await ref.read(unusedRandomTopicColorProvider.future); + + if (context.mounted) { + final result = await showDialog( + context: context, + builder: (context) => TopicDialog.create( + initialColor: initialColor, + ), + ); + + if (result != null) { + await ref + .read(topicRepositoryProvider.notifier) + .addTopic(name: result.name, color: result.color); + } + } + }, + icon: const Icon(Icons.add), + ), + ], + ), + body: HookConsumer( + builder: (context, ref, child) { + final topicsAsync = ref.watch(topicListProvider); + final selectedTopic = ref.watch(selectedTopicProvider); + + return Skeletonizer( + enabled: topicsAsync.isLoading, + child: topicsAsync.when( + data: (topics) => ListView.builder( + itemCount: topics.length, + itemBuilder: (context, index) { + final topic = topics[index]; + return _TopicTile( + topic, + key: ValueKey(topic.id), + isSelected: topic.id == selectedTopic, + onEdit: (edited) async { + await ref + .read(topicRepositoryProvider.notifier) + .replaceTopic( + id: topic.id, + name: edited.name, + color: edited.color, + ); + }, + onDelete: () async { + await ref + .read(topicRepositoryProvider.notifier) + .deleteTopic(topic.id); + }, + onTap: () { + ref + .read(selectedTopicProvider.notifier) + .toggleTopic(topic.id); + }, + ); + }, + ), + error: (error, stackTrace) => SizedBox.shrink(), + loading: () => ListView.builder( + itemCount: 3, + itemBuilder: (context, index) => _TopicTile( + const TopicData(id: 'null', color: Colors.transparent), + isSelected: false, + onEdit: (_) {}, + onDelete: () {}, + onTap: () {}, + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/app/lib/features/topics/presentation/widgets/material_color_picker.dart b/app/lib/features/topics/presentation/widgets/material_color_picker.dart new file mode 100644 index 00000000..58848ee3 --- /dev/null +++ b/app/lib/features/topics/presentation/widgets/material_color_picker.dart @@ -0,0 +1,340 @@ +// The Color Picker which contains Material Design Color Palette. +import 'package:fading_scroll/fading_scroll.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:lensai/features/topics/utils/color_palette.dart'; + +class MaterialPicker extends StatefulWidget { + const MaterialPicker({ + super.key, + required this.pickerColor, + required this.onColorChanged, + this.onPrimaryChanged, + this.enableLabel = false, + this.portraitOnly = false, + }); + + final Color pickerColor; + final ValueChanged onColorChanged; + final ValueChanged? onPrimaryChanged; + final bool enableLabel; + final bool portraitOnly; + + @override + State createState() => _MaterialPickerState(); +} + +class _MaterialPickerState extends State { + List _currentColorType = [Colors.red, Colors.redAccent]; + Color _currentShading = Colors.transparent; + + @override + void initState() { + for (final colors in colorTypes) { + shadingTypes(colors).forEach((Map color) { + if (widget.pickerColor.value == color.keys.first.value) { + return setState(() { + _currentColorType = colors; + _currentShading = color.keys.first; + }); + } + }); + } + super.initState(); + } + + @override + Widget build(BuildContext context) { + final isPortrait = + MediaQuery.of(context).orientation == Orientation.portrait || + widget.portraitOnly; + + Widget colorList() { + return Container( + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(), + child: Container( + margin: isPortrait + ? const EdgeInsets.only(right: 10) + : const EdgeInsets.only(bottom: 10), + width: isPortrait ? 60 : null, + height: isPortrait ? null : 60, + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + boxShadow: [ + BoxShadow( + color: (Theme.of(context).brightness == Brightness.light) + ? (Theme.of(context).brightness == Brightness.light) + ? Colors.grey[300]! + : Colors.black38 + : Colors.black38, + blurRadius: 10, + ), + ], + border: isPortrait + ? Border( + right: BorderSide( + color: (Theme.of(context).brightness == Brightness.light) + ? Colors.grey[300]! + : Colors.black38, + ), + ) + : Border( + top: BorderSide( + color: (Theme.of(context).brightness == Brightness.light) + ? Colors.grey[300]! + : Colors.black38, + ), + ), + ), + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith(dragDevices: PointerDeviceKind.values.toSet()), + child: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + scrollDirection: isPortrait ? Axis.vertical : Axis.horizontal, + children: [ + if (isPortrait) + const Padding(padding: EdgeInsets.only(top: 7)) + else + const Padding(padding: EdgeInsets.only(left: 7)), + ...colorTypes.map((List colors) { + final Color colorType = colors[0]; + return GestureDetector( + onTap: () { + if (widget.onPrimaryChanged != null) { + widget.onPrimaryChanged!.call(colorType); + } + setState(() => _currentColorType = colors); + }, + child: Container( + color: const Color(0x00000000), + padding: isPortrait + ? const EdgeInsets.fromLTRB(0, 7, 0, 7) + : const EdgeInsets.fromLTRB(7, 0, 7, 0), + child: Align( + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + width: 25, + height: 25, + decoration: BoxDecoration( + color: colorType, + shape: BoxShape.circle, + boxShadow: _currentColorType == colors + ? [ + if (colorType == + Theme.of(context).cardColor) + BoxShadow( + color: + (Theme.of(context).brightness == + Brightness.light) + ? Colors.grey[300]! + : Colors.black38, + blurRadius: 10, + ) + else + BoxShadow( + color: colorType, + blurRadius: 10, + ), + ] + : null, + border: colorType == Theme.of(context).cardColor + ? Border.all( + color: (Theme.of(context).brightness == + Brightness.light) + ? Colors.grey[300]! + : Colors.black38, + ) + : null, + ), + ), + ), + ), + ); + }), + if (isPortrait) + const Padding(padding: EdgeInsets.only(top: 5)) + else + const Padding(padding: EdgeInsets.only(left: 5)), + ], + ); + }, + ), + ), + ), + ); + } + + Widget shadingList() { + return ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith(dragDevices: PointerDeviceKind.values.toSet()), + child: FadingScroll( + fadingSize: 25, + builder: (context, controller) { + return ListView( + controller: controller, + scrollDirection: isPortrait ? Axis.vertical : Axis.horizontal, + children: [ + if (isPortrait) + const Padding(padding: EdgeInsets.only(top: 15)) + else + const Padding(padding: EdgeInsets.only(left: 15)), + ...shadingTypes(_currentColorType) + .map((Map colors) { + final Color color = colors.keys.first; + return GestureDetector( + onTap: () { + setState(() => _currentShading = color); + widget.onColorChanged(color); + }, + child: Container( + color: const Color(0x00000000), + margin: isPortrait + ? const EdgeInsets.only(right: 10) + : const EdgeInsets.only(bottom: 10), + padding: isPortrait + ? const EdgeInsets.fromLTRB(0, 7, 0, 7) + : const EdgeInsets.fromLTRB(7, 0, 7, 0), + child: Align( + child: AnimatedContainer( + curve: Curves.fastOutSlowIn, + duration: const Duration(milliseconds: 500), + width: isPortrait + ? (_currentShading == color ? 250 : 230) + : (_currentShading == color ? 50 : 30), + height: isPortrait ? 50 : 220, + decoration: BoxDecoration( + color: color, + boxShadow: _currentShading == color + ? [ + if ((color == Colors.white) || + (color == Colors.black)) + BoxShadow( + color: (Theme.of(context).brightness == + Brightness.light) + ? Colors.grey[300]! + : Colors.black38, + blurRadius: 10, + ) + else + BoxShadow( + color: color, + blurRadius: 10, + ), + ] + : null, + border: (color == Colors.white) || + (color == Colors.black) + ? Border.all( + color: (Theme.of(context).brightness == + Brightness.light) + ? Colors.grey[300]! + : Colors.black38, + ) + : null, + ), + child: widget.enableLabel + ? isPortrait + ? Row( + children: [ + Text( + ' ${colors.values.first}', + style: TextStyle( + color: useWhiteForeground(color) + ? Colors.white + : Colors.black, + ), + ), + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: Text( + '#${color.toString().replaceFirst('Color(0xff', '').replaceFirst(')', '').toUpperCase()} ', + style: TextStyle( + color: useWhiteForeground(color) + ? Colors.white + : Colors.black, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ) + : AnimatedOpacity( + duration: + const Duration(milliseconds: 300), + opacity: _currentShading == color ? 1 : 0, + child: Container( + padding: const EdgeInsets.only(top: 16), + alignment: Alignment.topCenter, + child: Text( + colors.values.first, + style: TextStyle( + color: useWhiteForeground(color) + ? Colors.white + : Colors.black, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + softWrap: false, + ), + ), + ) + : const SizedBox(), + ), + ), + ), + ); + }), + if (isPortrait) + const Padding(padding: EdgeInsets.only(top: 15)) + else + const Padding(padding: EdgeInsets.only(left: 15)), + ], + ); + }, + ), + ); + } + + if (isPortrait) { + return SizedBox( + width: 350, + height: 500, + child: Row( + children: [ + colorList(), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: shadingList(), + ), + ), + ], + ), + ); + } else { + return SizedBox( + width: 500, + height: 300, + child: Column( + children: [ + colorList(), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: shadingList(), + ), + ), + ], + ), + ); + } + } +} diff --git a/app/lib/features/topics/presentation/widgets/topic_chips.dart b/app/lib/features/topics/presentation/widgets/topic_chips.dart new file mode 100644 index 00000000..0415627a --- /dev/null +++ b/app/lib/features/topics/presentation/widgets/topic_chips.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lensai/core/routing/routes.dart'; +import 'package:lensai/features/topics/domain/providers.dart'; +import 'package:lensai/presentation/widgets/selectable_chips.dart'; + +class TopicChips extends HookConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final topicsAsync = ref.watch(topicListProvider); + final selectedTopic = ref + .watch(selectedTopicDataProvider.select((value) => value.valueOrNull)); + + return topicsAsync.when( + data: (availableTopics) => SizedBox( + height: 48, + child: Row( + children: [ + if (selectedTopic != null || availableTopics.isNotEmpty) + Expanded( + child: SelectableChips( + deleteIcon: false, + itemId: (topic) => topic.id, + itemAvatar: (topic) => Container( + width: 20.0, + height: 20.0, + decoration: BoxDecoration( + color: topic.color, + shape: BoxShape.circle, + ), + ), + itemLabel: (topic) => Text(topic.name ?? 'New Topic'), + availableItems: availableTopics, + selectedItem: selectedTopic, + onSelected: (topic) { + ref.read(selectedTopicProvider.notifier).setTopic(topic.id); + }, + onDeleted: (topic) async { + ref.read(selectedTopicProvider.notifier).clearTopic(); + }, + ), + ) + else + Expanded( + child: Text( + "Press '>' to manage Topics.", + style: TextStyle( + color: Theme.of(context).hintColor, + fontStyle: FontStyle.italic, + ), + ), + ), + IconButton( + onPressed: () async { + await context.push(TopicListRoute().location); + }, + icon: const Icon(Icons.chevron_right), + ), + ], + ), + ), + error: (error, stackTrace) => const SizedBox.shrink(), + loading: () => const SizedBox( + height: 48, + width: double.infinity, + ), + ); + } +} diff --git a/app/lib/features/topics/presentation/widgets/topic_dialog.dart b/app/lib/features/topics/presentation/widgets/topic_dialog.dart new file mode 100644 index 00000000..6ecc4c7b --- /dev/null +++ b/app/lib/features/topics/presentation/widgets/topic_dialog.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lensai/features/topics/presentation/widgets/material_color_picker.dart'; + +typedef TopicResult = ({String? name, Color color}); + +enum _DialogMode { create, edit } + +class TopicDialog extends HookWidget { + final _DialogMode _mode; + + final String? initialName; + final Color initialColor; + + const TopicDialog._({ + required _DialogMode mode, + required this.initialColor, + this.initialName, + }) : _mode = mode; + + factory TopicDialog.create({required Color initialColor}) { + return TopicDialog._( + mode: _DialogMode.create, + initialColor: initialColor, + ); + } + + factory TopicDialog.edit({ + required String? name, + required Color initialColor, + }) { + return TopicDialog._( + mode: _DialogMode.edit, + initialColor: initialColor, + initialName: name, + ); + } + + @override + Widget build(BuildContext context) { + final selectedColor = useState(initialColor); + final textController = useTextEditingController(text: initialName); + + return SimpleDialog( + titlePadding: const EdgeInsets.fromLTRB(24.0, 24.0, 24.0, 16.0), + contentPadding: const EdgeInsets.only( + left: 20.0, + right: 20.0, + bottom: 24.0, + ), + insetPadding: const EdgeInsets.symmetric( + horizontal: 20.0, + vertical: 24.0, + ), + title: Text( + switch (_mode) { + _DialogMode.create => 'New Topic', + _DialogMode.edit => 'Edit Topic', + }, + ), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: TextField( + decoration: InputDecoration( + prefixIcon: Padding( + padding: const EdgeInsets.all(10.0), + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: 24, + width: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: selectedColor.value, + ), + ), + ), + label: const Text('Name'), + ), + controller: textController, + ), + ), + const SizedBox(height: 16), + MaterialPicker( + pickerColor: selectedColor.value, + onColorChanged: (value) { + selectedColor.value = value; + }, + ), + const SizedBox(height: 24), + OverflowBar( + alignment: MainAxisAlignment.end, + spacing: 8.0, + children: [ + TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + final name = textController.text.trim(); + Navigator.pop( + context, + ( + name: name.isNotEmpty ? name : null, + color: selectedColor.value, + ), + ); + }, + child: Text( + switch (_mode) { + _DialogMode.create => 'Add', + _DialogMode.edit => 'Edit', + }, + ), + ), + ], + ), + ], + ); + } +} diff --git a/app/lib/features/topics/utils/color_palette.dart b/app/lib/features/topics/utils/color_palette.dart new file mode 100644 index 00000000..07bd9487 --- /dev/null +++ b/app/lib/features/topics/utils/color_palette.dart @@ -0,0 +1,91 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; + +final _rnd = Random(); + +/// Check if is good condition to use white foreground color by passing +/// the background color, and optional bias. +/// +/// Reference: +/// +/// Old: https://www.w3.org/TR/WCAG20-TECHS/G18.html +/// +/// New: https://github.com/mchome/flutter_statusbarcolor/issues/40 +bool useWhiteForeground(Color backgroundColor, {double bias = 0.0}) { + // Old: + // return 1.05 / (color.computeLuminance() + 0.05) > 4.5; + + // New: + final v = sqrt( + pow(backgroundColor.red, 2) * 0.299 + + pow(backgroundColor.green, 2) * 0.587 + + pow(backgroundColor.blue, 2) * 0.114, + ).round(); + return v < (130 + bias); +} + +const List> colorTypes = [ + [Colors.red, Colors.redAccent], + [Colors.pink, Colors.pinkAccent], + [Colors.purple, Colors.purpleAccent], + [Colors.deepPurple, Colors.deepPurpleAccent], + [Colors.indigo, Colors.indigoAccent], + [Colors.blue, Colors.blueAccent], + [Colors.lightBlue, Colors.lightBlueAccent], + [Colors.cyan, Colors.cyanAccent], + [Colors.teal, Colors.tealAccent], + [Colors.green, Colors.greenAccent], + [Colors.lightGreen, Colors.lightGreenAccent], + [Colors.lime, Colors.limeAccent], + [Colors.yellow, Colors.yellowAccent], + [Colors.amber, Colors.amberAccent], + [Colors.orange, Colors.orangeAccent], + [Colors.deepOrange, Colors.deepOrangeAccent], + [Colors.brown], + [Colors.grey], + [Colors.blueGrey], + [Colors.black], +]; + +List> shadingTypes(List colors) { + final List> result = []; + + for (final Color colorType in colors) { + if (colorType == Colors.grey) { + result.addAll( + [50, 100, 200, 300, 350, 400, 500, 600, 700, 800, 850, 900] + .map((int shade) => {Colors.grey[shade]!: shade.toString()}) + .toList(), + ); + } else if (colorType == Colors.black || colorType == Colors.white) { + result.addAll([ + {Colors.black: ''}, + {Colors.white: ''}, + ]); + } else if (colorType is MaterialAccentColor) { + result.addAll( + [100, 200, 400, 700] + .map((int shade) => {colorType[shade]!: 'A$shade'}) + .toList(), + ); + } else if (colorType is MaterialColor) { + result.addAll( + [50, 100, 200, 300, 400, 500, 600, 700, 800, 900] + .map((int shade) => {colorType[shade]!: shade.toString()}) + .toList(), + ); + } else { + result.add({const Color(0x00000000): ''}); + } + } + + return result; +} + +Color randomColorShade(List colors) { + final color = colors[_rnd.nextInt(colors.length)]; + final shades = shadingTypes([color]); + + return shades[_rnd.nextInt(shades.length)].keys.first; +} diff --git a/app/lib/features/web_view/domain/entities/abstract/tab.dart b/app/lib/features/web_view/domain/entities/abstract/tab.dart index ee92387d..144f703d 100644 --- a/app/lib/features/web_view/domain/entities/abstract/tab.dart +++ b/app/lib/features/web_view/domain/entities/abstract/tab.dart @@ -5,6 +5,7 @@ abstract interface class ITab { String get id; Uri get url; String? get title; + String? get topicId; Favicon? get favicon; Uint8List? get screenshot; } diff --git a/app/lib/features/web_view/domain/entities/web_view_page.dart b/app/lib/features/web_view/domain/entities/web_view_page.dart index d14f2ef1..854f6b62 100644 --- a/app/lib/features/web_view/domain/entities/web_view_page.dart +++ b/app/lib/features/web_view/domain/entities/web_view_page.dart @@ -1,4 +1,3 @@ -import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:fast_equatable/fast_equatable.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; @@ -7,17 +6,14 @@ import 'package:lensai/core/uuid.dart'; import 'package:lensai/domain/entities/web_page_info.dart'; import 'package:lensai/features/web_view/domain/entities/abstract/tab.dart'; -part 'web_view_page.g.dart'; - typedef PageHistory = ({bool canGoBack, bool canGoForward}); -@CopyWith(constructor: '_') class WebViewPage extends WebPageInfo with FastEquatable implements ITab { - @CopyWithField(immutable: true) + //@CopyWithField(immutable: true) final Key key; @override - @CopyWithField(immutable: true) + //@CopyWithField(immutable: true) final String id; final InAppWebViewController? controller; @@ -25,35 +21,40 @@ class WebViewPage extends WebPageInfo with FastEquatable implements ITab { // ignore: missing_field_in_equatable_props final SslError? sslError; + @override + final String? topicId; + @override final Uint8List? screenshot; final PageHistory pageHistory; WebViewPage({ - String? id, - this.controller, - required super.url, - this.sslError, - super.title, - super.favicon, - this.screenshot, - this.pageHistory = (canGoBack: false, canGoForward: false), - }) : key = GlobalKey(), - id = id ?? uuid.v7(); - - WebViewPage._({ required this.key, required this.id, required this.controller, required super.url, required this.sslError, required super.title, + required this.topicId, required super.favicon, required this.screenshot, required this.pageHistory, }); + WebViewPage.create({ + String? id, + this.controller, + required super.url, + this.sslError, + super.title, + this.topicId, + super.favicon, + this.screenshot, + this.pageHistory = (canGoBack: false, canGoForward: false), + }) : key = GlobalKey(), + id = id ?? uuid.v7(); + @override bool get cacheHash => true; @@ -65,6 +66,7 @@ class WebViewPage extends WebPageInfo with FastEquatable implements ITab { super.url, sslError?.toString(), title, + topicId, favicon?.toString(), screenshot, pageHistory, diff --git a/app/lib/features/web_view/domain/entities/web_view_page.g.dart b/app/lib/features/web_view/domain/entities/web_view_page.g.dart deleted file mode 100644 index 891d3222..00000000 --- a/app/lib/features/web_view/domain/entities/web_view_page.g.dart +++ /dev/null @@ -1,127 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'web_view_page.dart'; - -// ************************************************************************** -// CopyWithGenerator -// ************************************************************************** - -abstract class _$WebViewPageCWProxy { - WebViewPage controller(InAppWebViewController? controller); - - WebViewPage url(Uri url); - - WebViewPage sslError(SslError? sslError); - - WebViewPage title(String? title); - - WebViewPage favicon(Favicon? favicon); - - WebViewPage screenshot(Uint8List? screenshot); - - WebViewPage pageHistory(({bool canGoBack, bool canGoForward}) pageHistory); - - /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebViewPage(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. - /// - /// Usage - /// ```dart - /// WebViewPage(...).copyWith(id: 12, name: "My name") - /// ```` - WebViewPage call({ - InAppWebViewController? controller, - Uri? url, - SslError? sslError, - String? title, - Favicon? favicon, - Uint8List? screenshot, - ({bool canGoBack, bool canGoForward})? pageHistory, - }); -} - -/// Proxy class for `copyWith` functionality. This is a callable class and can be used as follows: `instanceOfWebViewPage.copyWith(...)`. Additionally contains functions for specific fields e.g. `instanceOfWebViewPage.copyWith.fieldName(...)` -class _$WebViewPageCWProxyImpl implements _$WebViewPageCWProxy { - const _$WebViewPageCWProxyImpl(this._value); - - final WebViewPage _value; - - @override - WebViewPage controller(InAppWebViewController? controller) => - this(controller: controller); - - @override - WebViewPage url(Uri url) => this(url: url); - - @override - WebViewPage sslError(SslError? sslError) => this(sslError: sslError); - - @override - WebViewPage title(String? title) => this(title: title); - - @override - WebViewPage favicon(Favicon? favicon) => this(favicon: favicon); - - @override - WebViewPage screenshot(Uint8List? screenshot) => this(screenshot: screenshot); - - @override - WebViewPage pageHistory(({bool canGoBack, bool canGoForward}) pageHistory) => - this(pageHistory: pageHistory); - - @override - - /// This function **does support** nullification of nullable fields. All `null` values passed to `non-nullable` fields will be ignored. You can also use `WebViewPage(...).copyWith.fieldName(...)` to override fields one at a time with nullification support. - /// - /// Usage - /// ```dart - /// WebViewPage(...).copyWith(id: 12, name: "My name") - /// ```` - WebViewPage call({ - Object? controller = const $CopyWithPlaceholder(), - Object? url = const $CopyWithPlaceholder(), - Object? sslError = const $CopyWithPlaceholder(), - Object? title = const $CopyWithPlaceholder(), - Object? favicon = const $CopyWithPlaceholder(), - Object? screenshot = const $CopyWithPlaceholder(), - Object? pageHistory = const $CopyWithPlaceholder(), - }) { - return WebViewPage._( - key: _value.key, - id: _value.id, - controller: controller == const $CopyWithPlaceholder() - ? _value.controller - // ignore: cast_nullable_to_non_nullable - : controller as InAppWebViewController?, - url: url == const $CopyWithPlaceholder() || url == null - ? _value.url - // ignore: cast_nullable_to_non_nullable - : url as Uri, - sslError: sslError == const $CopyWithPlaceholder() - ? _value.sslError - // ignore: cast_nullable_to_non_nullable - : sslError as SslError?, - title: title == const $CopyWithPlaceholder() - ? _value.title - // ignore: cast_nullable_to_non_nullable - : title as String?, - favicon: favicon == const $CopyWithPlaceholder() - ? _value.favicon - // ignore: cast_nullable_to_non_nullable - : favicon as Favicon?, - screenshot: screenshot == const $CopyWithPlaceholder() - ? _value.screenshot - // ignore: cast_nullable_to_non_nullable - : screenshot as Uint8List?, - pageHistory: - pageHistory == const $CopyWithPlaceholder() || pageHistory == null - ? _value.pageHistory - // ignore: cast_nullable_to_non_nullable - : pageHistory as ({bool canGoBack, bool canGoForward}), - ); - } -} - -extension $WebViewPageCopyWith on WebViewPage { - /// Returns a callable class that can be used as follows: `instanceOfWebViewPage.copyWith(...)` or like so:`instanceOfWebViewPage.copyWith.fieldName(...)`. - // ignore: library_private_types_in_public_api - _$WebViewPageCWProxy get copyWith => _$WebViewPageCWProxyImpl(this); -} diff --git a/app/lib/features/web_view/presentation/controllers/switch_new_tab.dart b/app/lib/features/web_view/presentation/controllers/switch_new_tab.dart index 99f7f9d1..76d9f1e8 100644 --- a/app/lib/features/web_view/presentation/controllers/switch_new_tab.dart +++ b/app/lib/features/web_view/presentation/controllers/switch_new_tab.dart @@ -15,7 +15,7 @@ class SwitchNewTabController extends _$SwitchNewTabController { state = const AsyncLoading(); state = await AsyncValue.guard( () async { - final newTab = WebViewPage( + final newTab = WebViewPage.create( url: WebUri.uri(url), ); diff --git a/app/lib/features/web_view/presentation/controllers/switch_new_tab.g.dart b/app/lib/features/web_view/presentation/controllers/switch_new_tab.g.dart index 9968cef0..9e64e197 100644 --- a/app/lib/features/web_view/presentation/controllers/switch_new_tab.g.dart +++ b/app/lib/features/web_view/presentation/controllers/switch_new_tab.g.dart @@ -7,7 +7,7 @@ part of 'switch_new_tab.dart'; // ************************************************************************** String _$switchNewTabControllerHash() => - r'a5b8f7fd71c8cb6cc2023b41504e5cd467c5b063'; + r'fb831015e4b99d3ae86bd2a88a3c6f4c7004783d'; /// See also [SwitchNewTabController]. @ProviderFor(SwitchNewTabController) diff --git a/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart b/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart index 1d466092..d221ffcf 100644 --- a/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart +++ b/app/lib/features/web_view/presentation/widgets/web_page_dialog.dart @@ -91,7 +91,7 @@ class WebPageDialog extends HookConsumerWidget { onDismiss: onDismiss, ), SimpleDialog( - titlePadding: const EdgeInsets.fromLTRB(24.0, 12.0, 24.0, 0.0), + titlePadding: const EdgeInsets.fromLTRB(16.0, 12.0, 16.0, 0.0), contentPadding: EdgeInsets.zero, insetPadding: const EdgeInsets.symmetric( horizontal: 20.0, diff --git a/app/lib/features/web_view/presentation/widgets/web_view.dart b/app/lib/features/web_view/presentation/widgets/web_view.dart index bae7c614..64baaab0 100644 --- a/app/lib/features/web_view/presentation/widgets/web_view.dart +++ b/app/lib/features/web_view/presentation/widgets/web_view.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; +import 'package:copy_with_extension/copy_with_extension.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -37,6 +38,109 @@ const _webViewSupportedSchemes = [ "about", ]; +abstract class _WebViewPageCWProxy { + WebViewPage controller(InAppWebViewController? controller); + + WebViewPage url(Uri url); + + WebViewPage sslError(SslError? sslError); + + WebViewPage title(String? title); + + WebViewPage topicId(String? topicId); + + WebViewPage favicon(Favicon? favicon); + + WebViewPage screenshot(Uint8List? screenshot); + + WebViewPage pageHistory(({bool canGoBack, bool canGoForward}) pageHistory); + + WebViewPage call({ + InAppWebViewController? controller, + Uri? url, + SslError? sslError, + String? title, + String? topicId, + Favicon? favicon, + Uint8List? screenshot, + ({bool canGoBack, bool canGoForward})? pageHistory, + }); +} + +class _WebViewPageCWProxyImpl implements _WebViewPageCWProxy { + const _WebViewPageCWProxyImpl(this._value); + + final WebViewPage _value; + + @override + WebViewPage controller(InAppWebViewController? controller) => + this(controller: controller); + + @override + WebViewPage url(Uri url) => this(url: url); + + @override + WebViewPage sslError(SslError? sslError) => this(sslError: sslError); + + @override + WebViewPage title(String? title) => this(title: title); + + @override + WebViewPage topicId(String? topicId) => this(topicId: topicId); + + @override + WebViewPage favicon(Favicon? favicon) => this(favicon: favicon); + + @override + WebViewPage screenshot(Uint8List? screenshot) => this(screenshot: screenshot); + + @override + WebViewPage pageHistory(({bool canGoBack, bool canGoForward}) pageHistory) => + this(pageHistory: pageHistory); + + @override + WebViewPage call({ + Object? controller = const $CopyWithPlaceholder(), + Object? url = const $CopyWithPlaceholder(), + Object? sslError = const $CopyWithPlaceholder(), + Object? title = const $CopyWithPlaceholder(), + Object? topicId = const $CopyWithPlaceholder(), + Object? favicon = const $CopyWithPlaceholder(), + Object? screenshot = const $CopyWithPlaceholder(), + Object? pageHistory = const $CopyWithPlaceholder(), + }) { + return WebViewPage( + key: _value.key, + id: _value.id, + controller: controller == const $CopyWithPlaceholder() + ? _value.controller + : controller as InAppWebViewController?, + url: url == const $CopyWithPlaceholder() || url == null + ? _value.url + : url as Uri, + sslError: sslError == const $CopyWithPlaceholder() + ? _value.sslError + : sslError as SslError?, + title: title == const $CopyWithPlaceholder() + ? _value.title + : title as String?, + topicId: topicId == const $CopyWithPlaceholder() + ? _value.topicId + : topicId as String?, + favicon: favicon == const $CopyWithPlaceholder() + ? _value.favicon + : favicon as Favicon?, + screenshot: screenshot == const $CopyWithPlaceholder() + ? _value.screenshot + : screenshot as Uint8List?, + pageHistory: + pageHistory == const $CopyWithPlaceholder() || pageHistory == null + ? _value.pageHistory + : pageHistory as ({bool canGoBack, bool canGoForward}), + ); + } +} + class WebView extends StatefulHookConsumerWidget { final String tabId; @@ -48,10 +152,6 @@ class WebView extends StatefulHookConsumerWidget { InAppWebViewController? get currentController => _pageNotifier.value.controller; - void updatePage(WebViewPage Function(WebViewPage page) update) { - _pageNotifier.value = update(_pageNotifier.value); - } - WebView({required WebViewPage tab}) : _pageNotifier = ValueNotifier(tab), tabId = tab.id, @@ -65,6 +165,13 @@ class _WebViewState extends ConsumerState { Timer? _onLoadStopDebounce; Timer? _periodicScreenshotUpdate; + void updatePage( + WebViewPage Function(_WebViewPageCWProxyImpl copyWith) update, + ) { + final x = widget._pageNotifier.value = + update(_WebViewPageCWProxyImpl(widget._pageNotifier.value)); + } + Future _downloadChat( DownloadStartRequest downloadStartRequest, BuildContext context, @@ -125,9 +232,7 @@ class _WebViewState extends ConsumerState { }, ); - widget.updatePage( - (page) => page.copyWith.screenshot(screenshot), - ); + updatePage((copyWith) => copyWith.screenshot(screenshot)); } @override @@ -286,7 +391,7 @@ class _WebViewState extends ConsumerState { await controller.startSafeBrowsing(); } - widget.updatePage((page) => page.copyWith.controller(controller)); + updatePage((copyWith) => copyWith.controller(controller)); }, onReceivedServerTrustAuthRequest: (controller, challenge) async { final sslError = challenge.protectionSpace.sslError; @@ -294,7 +399,7 @@ class _WebViewState extends ConsumerState { if (sslError != null && sslError.code != null) { if (challenge.protectionSpace.host == await controller.getUrl().then((value) => value?.host)) { - widget.updatePage((page) => page.copyWith.sslError(sslError)); + updatePage((copyWith) => copyWith.sslError(sslError)); if (context.mounted) { ui_helper.showErrorMessage( context, @@ -309,7 +414,7 @@ class _WebViewState extends ConsumerState { ); } - widget.updatePage((page) => page.copyWith.sslError(null)); + updatePage((copyWith) => copyWith.sslError(null)); return ServerTrustAuthResponse( action: ServerTrustAuthResponseAction.PROCEED, ); @@ -371,8 +476,8 @@ class _WebViewState extends ConsumerState { ); if (url != null) { - widget.updatePage( - (page) => page.copyWith( + updatePage( + (copyWith) => copyWith( url: url, // ignore: avoid_redundant_argument_values sslError: null, @@ -384,7 +489,7 @@ class _WebViewState extends ConsumerState { }, onLoadStop: (controller, url) async { if (url != null) { - widget.updatePage((page) => page.copyWith.url(url)); + updatePage((copyWith) => copyWith.url(url)); } _onLoadStopDebounce?.cancel(); @@ -417,7 +522,7 @@ class _WebViewState extends ConsumerState { canGoForward: await controller.canGoForward() ); - widget.updatePage((page) => page.copyWith.pageHistory(history)); + updatePage((copyWith) => copyWith.pageHistory(history)); } }, shouldOverrideUrlLoading: (controller, navigationAction) async { @@ -488,7 +593,7 @@ class _WebViewState extends ConsumerState { } }, onTitleChanged: (controller, title) { - widget.updatePage((page) => page.copyWith.title(title)); + updatePage((copyWith) => copyWith.title(title)); }, onDownloadStartRequest: (controller, downloadStartRequest) async { final handled = switch (downloadStartRequest.mimeType) { diff --git a/app/lib/presentation/widgets/selectable_chips.dart b/app/lib/presentation/widgets/selectable_chips.dart index 0afdef6f..ba8bc180 100644 --- a/app/lib/presentation/widgets/selectable_chips.dart +++ b/app/lib/presentation/widgets/selectable_chips.dart @@ -4,10 +4,11 @@ class SelectableChips extends StatelessWidget { final List availableItems; final T? selectedItem; final int maxCount; + final bool deleteIcon; final K Function(T item) itemId; final Widget Function(T item) itemLabel; - final Widget Function(T item)? itemAvatar; + final Widget? Function(T item)? itemAvatar; final void Function(T item)? onSelected; final void Function(T item)? onDeleted; @@ -19,6 +20,7 @@ class SelectableChips extends StatelessWidget { required this.availableItems, this.selectedItem, this.maxCount = 25, + this.deleteIcon = true, this.onSelected, this.onDeleted, super.key, @@ -63,9 +65,11 @@ class SelectableChips extends StatelessWidget { onDeleted?.call(item); } }, - onDeleted: () { - onDeleted?.call(item); - }, + onDeleted: deleteIcon + ? () { + onDeleted?.call(item); + } + : null, label: itemLabel.call(item), avatar: itemAvatar?.call(item), ), diff --git a/app/pubspec.lock b/app/pubspec.lock index 49409325..d0b4f1fb 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -326,6 +326,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.17" + fading_scroll: + dependency: "direct main" + description: + name: fading_scroll + sha256: "0eeb846385950dfb03415bf76cebf087e7ec21b4fe28fd0c864ce334b6f56f02" + url: "https://pub.dev" + source: hosted + version: "0.9.0" fake_async: dependency: transitive description: @@ -387,14 +395,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_colorpicker: - dependency: "direct main" - description: - name: flutter_colorpicker - sha256: "969de5f6f9e2a570ac660fb7b501551451ea2a1ab9e2097e89475f60e07816ea" - url: "https://pub.dev" - source: hosted - version: "1.1.0" flutter_hooks: dependency: "direct main" description: @@ -1435,5 +1435,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.5.0-259.0.dev <4.0.0" + dart: ">=3.5.0 <4.0.0" flutter: ">=3.22.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 169e0663..ea25cdcf 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -14,11 +14,11 @@ dependencies: dynamic_color: ^1.7.0 exceptions: ^0.6.1 expandable_page_view: ^1.0.17 + fading_scroll: ^0.9.0 fast_equatable: ^1.1.0 file_picker: ^8.1.2 flutter: sdk: flutter - flutter_colorpicker: ^1.1.0 flutter_hooks: ^0.20.5 flutter_inappwebview: ^6.0.0 flutter_markdown: ^0.7.3+1