topics intermediate

This commit is contained in:
Fabian Freund
2024-08-21 13:36:58 +02:00
parent e75052aa99
commit 7fedbe260c
35 changed files with 2511 additions and 198 deletions
+12
View File
@@ -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/chat_archive/presentation/screens/search.dart';
import 'package:lensai/features/search_browser/presentation/screens/browser.dart'; import 'package:lensai/features/search_browser/presentation/screens/browser.dart';
import 'package:lensai/features/settings/presentation/screens/settings.dart'; import 'package:lensai/features/settings/presentation/screens/settings.dart';
import 'package:lensai/features/topics/presentation/screens/topic_list.dart';
part 'routes.g.dart'; part 'routes.g.dart';
@@ -41,6 +42,10 @@ part 'routes.g.dart';
), ),
], ],
), ),
TypedGoRoute<TopicListRoute>(
name: 'TopicsRoute',
path: 'topics',
),
], ],
) )
class KagiRoute extends GoRouteData { 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<SettingsRoute>( @TypedGoRoute<SettingsRoute>(
name: 'SettingsRoute', name: 'SettingsRoute',
path: '/settings', path: '/settings',
+22
View File
@@ -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); 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<T?> push<T>(BuildContext context) => context.push<T>(location);
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
void replace(BuildContext context) => context.replace(location);
}
RouteBase get $settingsRoute => GoRouteData.$route( RouteBase get $settingsRoute => GoRouteData.$route(
path: '/settings', path: '/settings',
name: 'SettingsRoute', name: 'SettingsRoute',
@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/entities/abstract/tab.dart';
import 'package:lensai/features/web_view/domain/repositories/web_view.dart'; import 'package:lensai/features/web_view/domain/repositories/web_view.dart';
import 'package:lensai/features/web_view/presentation/controllers/switch_new_tab.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) { builder: (context, ref, child) {
//Fix layout issue https://github.com/flutter/flutter/issues/78748#issuecomment-1194680555 //Fix layout issue https://github.com/flutter/flutter/issues/78748#issuecomment-1194680555
return Align( return Align(
child: Row( child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextButton.icon( Row(
onPressed: () async { mainAxisAlignment: MainAxisAlignment.spaceBetween,
await ref children: [
.read(switchNewTabControllerProvider.notifier) TextButton.icon(
.add(Uri.https('kagi.com')); onPressed: () async {
await ref
.read(switchNewTabControllerProvider.notifier)
.add(Uri.https('kagi.com'));
onClose(); onClose();
}, },
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('New Tab'), label: const Text('New Tab'),
), ),
TextButton.icon( TextButton.icon(
onPressed: () { onPressed: () {
ref.read(webViewRepositoryProvider.notifier).closeAllTabs(); ref
onClose(); .read(webViewRepositoryProvider.notifier)
}, .closeAllTabs();
icon: const Icon(Icons.delete), onClose();
label: const Text('Close All'), },
icon: const Icon(Icons.delete),
label: const Text('Close All'),
),
],
), ),
TopicChips(),
], ],
), ),
); );
@@ -55,10 +64,10 @@ class _SliverHeaderDelagate extends SliverPersistentHeaderDelegate {
} }
@override @override
double get minExtent => 48; double get minExtent => 96;
@override @override
double get maxExtent => 48; double get maxExtent => 96;
@override @override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) =>
@@ -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<TabDatabase> with _$TabDaoMixin {
TabDao(super.db);
Future<void> 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<void> updateTab(
String id, {
Value<Uri> url = const Value.absent(),
Value<String?> title = const Value.absent(),
Value<String?> topicId = const Value.absent(),
Value<Uint8List?> 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,
),
);
}
}
@@ -0,0 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'tab.dart';
// ignore_for_file: type=lint
mixin _$TabDaoMixin on DatabaseAccessor<TabDatabase> {}
@@ -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<TabDatabase> with _$TopicDaoMixin {
TopicDao(super.db);
Future<void> addTopic({String? name, required Color color}) {
return db.topic.insertOne(
TopicCompanion.insert(
id: uuid.v7(),
name: Value(name),
color: color,
),
);
}
Future<void> replaceTopic(
String id, {
required String? name,
required Color color,
}) {
return db.topic.replaceOne(
TopicCompanion(
id: Value(id),
name: Value(name),
color: Value(color),
),
);
}
Future<void> deleteTopic(String id) {
return db.topic.deleteOne(TopicCompanion.custom(id: Variable(id)));
}
SingleOrNullSelectable<TopicData> getTopicData(String id) {
return select(db.topic)..where((t) => t.id.equals(id));
}
Selectable<Color> getDistinctColors() {
final query = db.selectOnly(db.topic, distinct: true)
..addColumns([db.topic.color])
..where(db.topic.color.isNotNull());
return query
.map((row) => row.readWithConverter<Color?, int>(db.topic.color)!);
}
}
@@ -0,0 +1,6 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'topic.dart';
// ignore_for_file: type=lint
mixin _$TopicDaoMixin on DatabaseAccessor<TabDatabase> {}
@@ -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);
}
@@ -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;
@@ -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<Topic, TopicData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
Topic(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id', aliasedName, false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL');
late final GeneratedColumn<String> name = GeneratedColumn<String>(
'name', aliasedName, true,
type: DriftSqlType.string,
requiredDuringInsert: false,
$customConstraints: '');
late final GeneratedColumnWithTypeConverter<Color, int> color =
GeneratedColumn<int>('color', aliasedName, false,
type: DriftSqlType.int,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL')
.withConverter<Color>(Topic.$convertercolor);
@override
List<GeneratedColumn> get $columns => [id, name, color];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'topic';
@override
Set<GeneratedColumn> get $primaryKey => {id};
@override
TopicData map(Map<String, dynamic> 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<Color, int> $convertercolor = const ColorConverter();
@override
bool get dontWriteConstraints => true;
}
class TopicData extends DataClass implements Insertable<TopicData> {
final String id;
final String? name;
final Color color;
const TopicData({required this.id, this.name, required this.color});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
if (!nullToAbsent || name != null) {
map['name'] = Variable<String>(name);
}
{
map['color'] = Variable<int>(Topic.$convertercolor.toSql(color));
}
return map;
}
factory TopicData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return TopicData(
id: serializer.fromJson<String>(json['id']),
name: serializer.fromJson<String?>(json['name']),
color: serializer.fromJson<Color>(json['color']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'name': serializer.toJson<String?>(name),
'color': serializer.toJson<Color>(color),
};
}
TopicData copyWith(
{String? id,
Value<String?> 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<TopicData> {
final Value<String> id;
final Value<String?> name;
final Value<Color> color;
final Value<int> 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<TopicData> custom({
Expression<String>? id,
Expression<String>? name,
Expression<int>? color,
Expression<int>? 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<String>? id,
Value<String?>? name,
Value<Color>? color,
Value<int>? rowid}) {
return TopicCompanion(
id: id ?? this.id,
name: name ?? this.name,
color: color ?? this.color,
rowid: rowid ?? this.rowid,
);
}
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (name.present) {
map['name'] = Variable<String>(name.value);
}
if (color.present) {
map['color'] = Variable<int>(Topic.$convertercolor.toSql(color.value));
}
if (rowid.present) {
map['rowid'] = Variable<int>(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<Tab, TabData> {
@override
final GeneratedDatabase attachedDatabase;
final String? _alias;
Tab(this.attachedDatabase, [this._alias]);
late final GeneratedColumn<String> id = GeneratedColumn<String>(
'id', aliasedName, false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'PRIMARY KEY NOT NULL');
late final GeneratedColumn<String> topicId = GeneratedColumn<String>(
'topic_id', aliasedName, true,
type: DriftSqlType.string,
requiredDuringInsert: false,
$customConstraints: 'REFERENCES topic(id)ON DELETE CASCADE');
late final GeneratedColumn<DateTime> timestamp = GeneratedColumn<DateTime>(
'timestamp', aliasedName, false,
type: DriftSqlType.dateTime,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL');
late final GeneratedColumnWithTypeConverter<Uri, String> url =
GeneratedColumn<String>('url', aliasedName, false,
type: DriftSqlType.string,
requiredDuringInsert: true,
$customConstraints: 'NOT NULL')
.withConverter<Uri>(Tab.$converterurl);
late final GeneratedColumn<String> title = GeneratedColumn<String>(
'title', aliasedName, true,
type: DriftSqlType.string,
requiredDuringInsert: false,
$customConstraints: '');
late final GeneratedColumn<Uint8List> screenshot = GeneratedColumn<Uint8List>(
'screenshot', aliasedName, true,
type: DriftSqlType.blob,
requiredDuringInsert: false,
$customConstraints: '');
@override
List<GeneratedColumn> 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<GeneratedColumn> get $primaryKey => {id};
@override
TabData map(Map<String, dynamic> 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<Uri, String> $converterurl = const UriConverter();
@override
bool get dontWriteConstraints => true;
}
class TabData extends DataClass implements Insertable<TabData> {
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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
map['id'] = Variable<String>(id);
if (!nullToAbsent || topicId != null) {
map['topic_id'] = Variable<String>(topicId);
}
map['timestamp'] = Variable<DateTime>(timestamp);
{
map['url'] = Variable<String>(Tab.$converterurl.toSql(url));
}
if (!nullToAbsent || title != null) {
map['title'] = Variable<String>(title);
}
if (!nullToAbsent || screenshot != null) {
map['screenshot'] = Variable<Uint8List>(screenshot);
}
return map;
}
factory TabData.fromJson(Map<String, dynamic> json,
{ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return TabData(
id: serializer.fromJson<String>(json['id']),
topicId: serializer.fromJson<String?>(json['topic_id']),
timestamp: serializer.fromJson<DateTime>(json['timestamp']),
url: serializer.fromJson<Uri>(json['url']),
title: serializer.fromJson<String?>(json['title']),
screenshot: serializer.fromJson<Uint8List?>(json['screenshot']),
);
}
@override
Map<String, dynamic> toJson({ValueSerializer? serializer}) {
serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'id': serializer.toJson<String>(id),
'topic_id': serializer.toJson<String?>(topicId),
'timestamp': serializer.toJson<DateTime>(timestamp),
'url': serializer.toJson<Uri>(url),
'title': serializer.toJson<String?>(title),
'screenshot': serializer.toJson<Uint8List?>(screenshot),
};
}
TabData copyWith(
{String? id,
Value<String?> topicId = const Value.absent(),
DateTime? timestamp,
Uri? url,
Value<String?> title = const Value.absent(),
Value<Uint8List?> 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<TabData> {
final Value<String> id;
final Value<String?> topicId;
final Value<DateTime> timestamp;
final Value<Uri> url;
final Value<String?> title;
final Value<Uint8List?> screenshot;
final Value<int> 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<TabData> custom({
Expression<String>? id,
Expression<String>? topicId,
Expression<DateTime>? timestamp,
Expression<String>? url,
Expression<String>? title,
Expression<Uint8List>? screenshot,
Expression<int>? 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<String>? id,
Value<String?>? topicId,
Value<DateTime>? timestamp,
Value<Uri>? url,
Value<String?>? title,
Value<Uint8List?>? screenshot,
Value<int>? 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<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{};
if (id.present) {
map['id'] = Variable<String>(id.value);
}
if (topicId.present) {
map['topic_id'] = Variable<String>(topicId.value);
}
if (timestamp.present) {
map['timestamp'] = Variable<DateTime>(timestamp.value);
}
if (url.present) {
map['url'] = Variable<String>(Tab.$converterurl.toSql(url.value));
}
if (title.present) {
map['title'] = Variable<String>(title.value);
}
if (screenshot.present) {
map['screenshot'] = Variable<Uint8List>(screenshot.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(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<TopicData> 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<TableInfo<Table, Object?>> get allTables =>
allSchemaEntities.whereType<TableInfo<Table, Object?>>();
@override
List<DatabaseSchemaEntity> 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<String?> name,
required Color color,
Value<int> rowid,
});
typedef $TopicUpdateCompanionBuilder = TopicCompanion Function({
Value<String> id,
Value<String?> name,
Value<Color> color,
Value<int> rowid,
});
final class $TopicReferences
extends BaseReferences<_$TabDatabase, Topic, TopicData> {
$TopicReferences(super.$_db, super.$_table, super.$_typedResult);
static MultiTypedResultKey<Tab, List<TabData>> _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<String> get id => $state.composableBuilder(
column: $state.table.id,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnFilters<String> get name => $state.composableBuilder(
column: $state.table.name,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnWithTypeConverterFilters<Color, Color, int> 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<String> get id => $state.composableBuilder(
column: $state.table.id,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get name => $state.composableBuilder(
column: $state.table.name,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<int> 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<String> id = const Value.absent(),
Value<String?> name = const Value.absent(),
Value<Color> color = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) =>
TopicCompanion(
id: id,
name: name,
color: color,
rowid: rowid,
),
createCompanionCallback: ({
required String id,
Value<String?> name = const Value.absent(),
required Color color,
Value<int> 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<String?> topicId,
required DateTime timestamp,
required Uri url,
Value<String?> title,
Value<Uint8List?> screenshot,
Value<int> rowid,
});
typedef $TabUpdateCompanionBuilder = TabCompanion Function({
Value<String> id,
Value<String?> topicId,
Value<DateTime> timestamp,
Value<Uri> url,
Value<String?> title,
Value<Uint8List?> screenshot,
Value<int> 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<String> get id => $state.composableBuilder(
column: $state.table.id,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnFilters<DateTime> get timestamp => $state.composableBuilder(
column: $state.table.timestamp,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnWithTypeConverterFilters<Uri, Uri, String> get url =>
$state.composableBuilder(
column: $state.table.url,
builder: (column, joinBuilders) => ColumnWithTypeConverterFilters(
column,
joinBuilders: joinBuilders));
ColumnFilters<String> get title => $state.composableBuilder(
column: $state.table.title,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnFilters<Uint8List> 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<String> get id => $state.composableBuilder(
column: $state.table.id,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<DateTime> get timestamp => $state.composableBuilder(
column: $state.table.timestamp,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get url => $state.composableBuilder(
column: $state.table.url,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get title => $state.composableBuilder(
column: $state.table.title,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<Uint8List> 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<String> id = const Value.absent(),
Value<String?> topicId = const Value.absent(),
Value<DateTime> timestamp = const Value.absent(),
Value<Uri> url = const Value.absent(),
Value<String?> title = const Value.absent(),
Value<Uint8List?> screenshot = const Value.absent(),
Value<int> rowid = const Value.absent(),
}) =>
TabCompanion(
id: id,
topicId: topicId,
timestamp: timestamp,
url: url,
title: title,
screenshot: screenshot,
rowid: rowid,
),
createCompanionCallback: ({
required String id,
Value<String?> topicId = const Value.absent(),
required DateTime timestamp,
required Uri url,
Value<String?> title = const Value.absent(),
Value<Uint8List?> screenshot = const Value.absent(),
Value<int> 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);
}
@@ -0,0 +1,17 @@
import 'dart:ui';
import 'package:drift/drift.dart';
class ColorConverter extends TypeConverter<Color, int> {
const ColorConverter();
@override
Color fromSql(int fromDb) {
return Color(fromDb);
}
@override
int toSql(Color value) {
return value.value;
}
}
@@ -0,0 +1,14 @@
import 'package:drift/drift.dart';
import 'package:lensai/utils/uri_parser.dart' as uri_parser;
class UriConverter extends TypeConverter<Uri, String> {
@override
Uri fromSql(String fromDb) {
return uri_parser.tryParseUrl(fromDb, eagerParsing: true)!;
}
@override
String toSql(Uri value) {
return value.toString();
}
}
@@ -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);
}),
);
}
@@ -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<TabDatabase>.internal(
tabDatabase,
name: r'tabDatabaseProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$tabDatabaseHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef TabDatabaseRef = ProviderRef<TabDatabase>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -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<List<TopicData>> 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<TopicData?> selectedTopicData(SelectedTopicDataRef ref) {
final repository = ref.watch(topicRepositoryProvider.notifier);
final selectedBangTrigger = ref.watch(selectedTopicProvider);
return repository.watchTopic(selectedBangTrigger);
}
@Riverpod()
Future<Set<Color>> distinctTopicColors(DistinctTopicColorsRef ref) {
final repository = ref.watch(topicRepositoryProvider.notifier);
return repository.getDistinctColors();
}
@Riverpod()
Future<Color> 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;
}
@@ -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<List<TopicData>>.internal(
topicList,
name: r'topicListProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product') ? null : _$topicListHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef TopicListRef = AutoDisposeStreamProviderRef<List<TopicData>>;
String _$selectedTopicDataHash() => r'191ad088ab90ef2348a836593f92b48d095afdc8';
/// See also [selectedTopicData].
@ProviderFor(selectedTopicData)
final selectedTopicDataProvider =
AutoDisposeStreamProvider<TopicData?>.internal(
selectedTopicData,
name: r'selectedTopicDataProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$selectedTopicDataHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef SelectedTopicDataRef = AutoDisposeStreamProviderRef<TopicData?>;
String _$distinctTopicColorsHash() =>
r'c671f349615313d3b17e93e9e46fae12889e5e1f';
/// See also [distinctTopicColors].
@ProviderFor(distinctTopicColors)
final distinctTopicColorsProvider =
AutoDisposeFutureProvider<Set<Color>>.internal(
distinctTopicColors,
name: r'distinctTopicColorsProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$distinctTopicColorsHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef DistinctTopicColorsRef = AutoDisposeFutureProviderRef<Set<Color>>;
String _$unusedRandomTopicColorHash() =>
r'8f6907bb50a5bf2ac0320e93531d9e61616337b9';
/// See also [unusedRandomTopicColor].
@ProviderFor(unusedRandomTopicColor)
final unusedRandomTopicColorProvider =
AutoDisposeFutureProvider<Color>.internal(
unusedRandomTopicColor,
name: r'unusedRandomTopicColorProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$unusedRandomTopicColorHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef UnusedRandomTopicColorRef = AutoDisposeFutureProviderRef<Color>;
String _$selectedTopicHash() => r'f4e1c0620971a0b7501ee9aff0d5b41da3130ba0';
/// See also [SelectedTopic].
@ProviderFor(SelectedTopic)
final selectedTopicProvider = NotifierProvider<SelectedTopic, String?>.internal(
SelectedTopic.new,
name: r'selectedTopicProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$selectedTopicHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$SelectedTopic = Notifier<String?>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -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);
}
}
@@ -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<TabRepository, void>.internal(
TabRepository.new,
name: r'tabRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$tabRepositoryHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$TabRepository = Notifier<void>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -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<void> addTopic({required String? name, required Color color}) {
return _db.topicDao.addTopic(name: name, color: color);
}
Future<void> replaceTopic({
required String id,
required String? name,
required Color color,
}) {
return _db.topicDao.replaceTopic(id, name: name, color: color);
}
Future<void> deleteTopic(String id) {
return _db.topicDao.deleteTopic(id);
}
Stream<List<TopicData>> watchTopics() {
return _db.topics().watch();
}
Stream<TopicData?> watchTopic(String? id) {
if (id != null) {
return _db.topicDao.getTopicData(id).watchSingleOrNull();
} else {
return Stream.value(null);
}
}
Future<Set<Color>> getDistinctColors() {
return _db.topicDao
.getDistinctColors()
.get()
.then((colors) => colors.toSet());
}
}
@@ -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<TopicRepository, void>.internal(
TopicRepository.new,
name: r'topicRepositoryProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
? null
: _$topicRepositoryHash,
dependencies: null,
allTransitiveDependencies: null,
);
typedef _$TopicRepository = Notifier<void>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member
@@ -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<TopicResult?>(
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<bool?>(
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: <Widget>[
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<TopicResult?>(
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: () {},
),
),
),
);
},
),
);
}
}
@@ -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<Color> onColorChanged;
final ValueChanged<Color>? onPrimaryChanged;
final bool enableLabel;
final bool portraitOnly;
@override
State<StatefulWidget> createState() => _MaterialPickerState();
}
class _MaterialPickerState extends State<MaterialPicker> {
List<Color> _currentColorType = [Colors.red, Colors.redAccent];
Color _currentShading = Colors.transparent;
@override
void initState() {
for (final colors in colorTypes) {
shadingTypes(colors).forEach((Map<Color, String> 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<Color> 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<Color, String> 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: <Widget>[
colorList(),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: shadingList(),
),
),
],
),
);
} else {
return SizedBox(
width: 500,
height: 300,
child: Column(
children: <Widget>[
colorList(),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: shadingList(),
),
),
],
),
);
}
}
}
@@ -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,
),
);
}
}
@@ -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<Color>(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<TopicResult?>(context);
},
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
final name = textController.text.trim();
Navigator.pop<TopicResult?>(
context,
(
name: name.isNotEmpty ? name : null,
color: selectedColor.value,
),
);
},
child: Text(
switch (_mode) {
_DialogMode.create => 'Add',
_DialogMode.edit => 'Edit',
},
),
),
],
),
],
);
}
}
@@ -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<List<Color>> 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<Map<Color, String>> shadingTypes(List<Color> colors) {
final List<Map<Color, String>> 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<Color> colors) {
final color = colors[_rnd.nextInt(colors.length)];
final shades = shadingTypes([color]);
return shades[_rnd.nextInt(shades.length)].keys.first;
}
@@ -5,6 +5,7 @@ abstract interface class ITab {
String get id; String get id;
Uri get url; Uri get url;
String? get title; String? get title;
String? get topicId;
Favicon? get favicon; Favicon? get favicon;
Uint8List? get screenshot; Uint8List? get screenshot;
} }
@@ -1,4 +1,3 @@
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:fast_equatable/fast_equatable.dart'; import 'package:fast_equatable/fast_equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.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/domain/entities/web_page_info.dart';
import 'package:lensai/features/web_view/domain/entities/abstract/tab.dart'; import 'package:lensai/features/web_view/domain/entities/abstract/tab.dart';
part 'web_view_page.g.dart';
typedef PageHistory = ({bool canGoBack, bool canGoForward}); typedef PageHistory = ({bool canGoBack, bool canGoForward});
@CopyWith(constructor: '_')
class WebViewPage extends WebPageInfo with FastEquatable implements ITab { class WebViewPage extends WebPageInfo with FastEquatable implements ITab {
@CopyWithField(immutable: true) //@CopyWithField(immutable: true)
final Key key; final Key key;
@override @override
@CopyWithField(immutable: true) //@CopyWithField(immutable: true)
final String id; final String id;
final InAppWebViewController? controller; final InAppWebViewController? controller;
@@ -25,35 +21,40 @@ class WebViewPage extends WebPageInfo with FastEquatable implements ITab {
// ignore: missing_field_in_equatable_props // ignore: missing_field_in_equatable_props
final SslError? sslError; final SslError? sslError;
@override
final String? topicId;
@override @override
final Uint8List? screenshot; final Uint8List? screenshot;
final PageHistory pageHistory; final PageHistory pageHistory;
WebViewPage({ 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.key,
required this.id, required this.id,
required this.controller, required this.controller,
required super.url, required super.url,
required this.sslError, required this.sslError,
required super.title, required super.title,
required this.topicId,
required super.favicon, required super.favicon,
required this.screenshot, required this.screenshot,
required this.pageHistory, 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 @override
bool get cacheHash => true; bool get cacheHash => true;
@@ -65,6 +66,7 @@ class WebViewPage extends WebPageInfo with FastEquatable implements ITab {
super.url, super.url,
sslError?.toString(), sslError?.toString(),
title, title,
topicId,
favicon?.toString(), favicon?.toString(),
screenshot, screenshot,
pageHistory, pageHistory,
@@ -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);
}
@@ -15,7 +15,7 @@ class SwitchNewTabController extends _$SwitchNewTabController {
state = const AsyncLoading(); state = const AsyncLoading();
state = await AsyncValue.guard( state = await AsyncValue.guard(
() async { () async {
final newTab = WebViewPage( final newTab = WebViewPage.create(
url: WebUri.uri(url), url: WebUri.uri(url),
); );
@@ -7,7 +7,7 @@ part of 'switch_new_tab.dart';
// ************************************************************************** // **************************************************************************
String _$switchNewTabControllerHash() => String _$switchNewTabControllerHash() =>
r'a5b8f7fd71c8cb6cc2023b41504e5cd467c5b063'; r'fb831015e4b99d3ae86bd2a88a3c6f4c7004783d';
/// See also [SwitchNewTabController]. /// See also [SwitchNewTabController].
@ProviderFor(SwitchNewTabController) @ProviderFor(SwitchNewTabController)
@@ -91,7 +91,7 @@ class WebPageDialog extends HookConsumerWidget {
onDismiss: onDismiss, onDismiss: onDismiss,
), ),
SimpleDialog( 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, contentPadding: EdgeInsets.zero,
insetPadding: const EdgeInsets.symmetric( insetPadding: const EdgeInsets.symmetric(
horizontal: 20.0, horizontal: 20.0,
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:copy_with_extension/copy_with_extension.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
@@ -37,6 +38,109 @@ const _webViewSupportedSchemes = [
"about", "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 { class WebView extends StatefulHookConsumerWidget {
final String tabId; final String tabId;
@@ -48,10 +152,6 @@ class WebView extends StatefulHookConsumerWidget {
InAppWebViewController? get currentController => InAppWebViewController? get currentController =>
_pageNotifier.value.controller; _pageNotifier.value.controller;
void updatePage(WebViewPage Function(WebViewPage page) update) {
_pageNotifier.value = update(_pageNotifier.value);
}
WebView({required WebViewPage tab}) WebView({required WebViewPage tab})
: _pageNotifier = ValueNotifier(tab), : _pageNotifier = ValueNotifier(tab),
tabId = tab.id, tabId = tab.id,
@@ -65,6 +165,13 @@ class _WebViewState extends ConsumerState<WebView> {
Timer? _onLoadStopDebounce; Timer? _onLoadStopDebounce;
Timer? _periodicScreenshotUpdate; Timer? _periodicScreenshotUpdate;
void updatePage(
WebViewPage Function(_WebViewPageCWProxyImpl copyWith) update,
) {
final x = widget._pageNotifier.value =
update(_WebViewPageCWProxyImpl(widget._pageNotifier.value));
}
Future<bool> _downloadChat( Future<bool> _downloadChat(
DownloadStartRequest downloadStartRequest, DownloadStartRequest downloadStartRequest,
BuildContext context, BuildContext context,
@@ -125,9 +232,7 @@ class _WebViewState extends ConsumerState<WebView> {
}, },
); );
widget.updatePage( updatePage((copyWith) => copyWith.screenshot(screenshot));
(page) => page.copyWith.screenshot(screenshot),
);
} }
@override @override
@@ -286,7 +391,7 @@ class _WebViewState extends ConsumerState<WebView> {
await controller.startSafeBrowsing(); await controller.startSafeBrowsing();
} }
widget.updatePage((page) => page.copyWith.controller(controller)); updatePage((copyWith) => copyWith.controller(controller));
}, },
onReceivedServerTrustAuthRequest: (controller, challenge) async { onReceivedServerTrustAuthRequest: (controller, challenge) async {
final sslError = challenge.protectionSpace.sslError; final sslError = challenge.protectionSpace.sslError;
@@ -294,7 +399,7 @@ class _WebViewState extends ConsumerState<WebView> {
if (sslError != null && sslError.code != null) { if (sslError != null && sslError.code != null) {
if (challenge.protectionSpace.host == if (challenge.protectionSpace.host ==
await controller.getUrl().then((value) => value?.host)) { await controller.getUrl().then((value) => value?.host)) {
widget.updatePage((page) => page.copyWith.sslError(sslError)); updatePage((copyWith) => copyWith.sslError(sslError));
if (context.mounted) { if (context.mounted) {
ui_helper.showErrorMessage( ui_helper.showErrorMessage(
context, context,
@@ -309,7 +414,7 @@ class _WebViewState extends ConsumerState<WebView> {
); );
} }
widget.updatePage((page) => page.copyWith.sslError(null)); updatePage((copyWith) => copyWith.sslError(null));
return ServerTrustAuthResponse( return ServerTrustAuthResponse(
action: ServerTrustAuthResponseAction.PROCEED, action: ServerTrustAuthResponseAction.PROCEED,
); );
@@ -371,8 +476,8 @@ class _WebViewState extends ConsumerState<WebView> {
); );
if (url != null) { if (url != null) {
widget.updatePage( updatePage(
(page) => page.copyWith( (copyWith) => copyWith(
url: url, url: url,
// ignore: avoid_redundant_argument_values // ignore: avoid_redundant_argument_values
sslError: null, sslError: null,
@@ -384,7 +489,7 @@ class _WebViewState extends ConsumerState<WebView> {
}, },
onLoadStop: (controller, url) async { onLoadStop: (controller, url) async {
if (url != null) { if (url != null) {
widget.updatePage((page) => page.copyWith.url(url)); updatePage((copyWith) => copyWith.url(url));
} }
_onLoadStopDebounce?.cancel(); _onLoadStopDebounce?.cancel();
@@ -417,7 +522,7 @@ class _WebViewState extends ConsumerState<WebView> {
canGoForward: await controller.canGoForward() canGoForward: await controller.canGoForward()
); );
widget.updatePage((page) => page.copyWith.pageHistory(history)); updatePage((copyWith) => copyWith.pageHistory(history));
} }
}, },
shouldOverrideUrlLoading: (controller, navigationAction) async { shouldOverrideUrlLoading: (controller, navigationAction) async {
@@ -488,7 +593,7 @@ class _WebViewState extends ConsumerState<WebView> {
} }
}, },
onTitleChanged: (controller, title) { onTitleChanged: (controller, title) {
widget.updatePage((page) => page.copyWith.title(title)); updatePage((copyWith) => copyWith.title(title));
}, },
onDownloadStartRequest: (controller, downloadStartRequest) async { onDownloadStartRequest: (controller, downloadStartRequest) async {
final handled = switch (downloadStartRequest.mimeType) { final handled = switch (downloadStartRequest.mimeType) {
@@ -4,10 +4,11 @@ class SelectableChips<T, K> extends StatelessWidget {
final List<T> availableItems; final List<T> availableItems;
final T? selectedItem; final T? selectedItem;
final int maxCount; final int maxCount;
final bool deleteIcon;
final K Function(T item) itemId; final K Function(T item) itemId;
final Widget Function(T item) itemLabel; 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)? onSelected;
final void Function(T item)? onDeleted; final void Function(T item)? onDeleted;
@@ -19,6 +20,7 @@ class SelectableChips<T, K> extends StatelessWidget {
required this.availableItems, required this.availableItems,
this.selectedItem, this.selectedItem,
this.maxCount = 25, this.maxCount = 25,
this.deleteIcon = true,
this.onSelected, this.onSelected,
this.onDeleted, this.onDeleted,
super.key, super.key,
@@ -63,9 +65,11 @@ class SelectableChips<T, K> extends StatelessWidget {
onDeleted?.call(item); onDeleted?.call(item);
} }
}, },
onDeleted: () { onDeleted: deleteIcon
onDeleted?.call(item); ? () {
}, onDeleted?.call(item);
}
: null,
label: itemLabel.call(item), label: itemLabel.call(item),
avatar: itemAvatar?.call(item), avatar: itemAvatar?.call(item),
), ),
+9 -9
View File
@@ -326,6 +326,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.17" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -387,14 +395,6 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: flutter_hooks:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -1435,5 +1435,5 @@ packages:
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
sdks: sdks:
dart: ">=3.5.0-259.0.dev <4.0.0" dart: ">=3.5.0 <4.0.0"
flutter: ">=3.22.0" flutter: ">=3.22.0"
+1 -1
View File
@@ -14,11 +14,11 @@ dependencies:
dynamic_color: ^1.7.0 dynamic_color: ^1.7.0
exceptions: ^0.6.1 exceptions: ^0.6.1
expandable_page_view: ^1.0.17 expandable_page_view: ^1.0.17
fading_scroll: ^0.9.0
fast_equatable: ^1.1.0 fast_equatable: ^1.1.0
file_picker: ^8.1.2 file_picker: ^8.1.2
flutter: flutter:
sdk: flutter sdk: flutter
flutter_colorpicker: ^1.1.0
flutter_hooks: ^0.20.5 flutter_hooks: ^0.20.5
flutter_inappwebview: ^6.0.0 flutter_inappwebview: ^6.0.0
flutter_markdown: ^0.7.3+1 flutter_markdown: ^0.7.3+1