Google Firestore:查询 属性 值的子字符串(文本搜索)

Google Firestore: Query on substring of a property value (text search)

我想添加一个简单的搜索字段,想使用类似

的东西

collectionRef.where('name', 'contains', 'searchTerm')

我尝试使用 where('name', '==', '%searchTerm%'),但它没有 return 任何东西。

没有这样的运算符,允许的是==<<=>>=

您只能按前缀过滤,例如,对于从 barfoo 之间的所有内容,您可以使用

collectionRef
    .where('name', '>=', 'bar')
    .where('name', '<=', 'foo')

您可以使用 Algolia 或 ElasticSearch 等外部服务。

虽然 Kuba 的回答就限制而言是正确的,但您可以使用类似集合的结构部分地模拟它:

{
  'terms': {
    'reebok': true,
    'mens': true,
    'tennis': true,
    'racket': true
  }
}

现在您可以使用

查询
collectionRef.where('terms.tennis', '==', true)

之所以可行,是因为 Firestore 会自动为每个字段创建索引。不幸的是,这不能直接用于复合查询,因为 Firestore 不会自动创建复合索引。

您仍然可以通过存储单词组合来解决这个问题,但这很快就会变得难看。

你可能还是用舷外机更好full text search

我们可以使用反引号打印出字符串的值。这应该有效:

where('name', '==', `${searchTerm}`)

根据 Firestore docs,Cloud Firestore 不支持本机索引或搜索文档中的文本字段。此外,下载整个集合以在客户端搜索字段是不切实际的。

推荐使用 Algolia and Elastic Search 等第三方搜索解决方案。

虽然 Firebase 不明确支持在字符串中搜索术语,

Firebase(现在)支持以下内容,这将解决您的问题和许多其他问题:

截至 2018 年 8 月,他们支持 array-contains 查询。参见:https://firebase.googleblog.com/2018/08/better-arrays-in-cloud-firestore.html

您现在可以将所有关键术语作为字段设置到一个数组中,然后查询具有包含 'X' 的数组的所有文档。您可以使用逻辑 AND 对 其他查询进行进一步比较。(这是因为 firebase does not currently natively support compound queries for multiple array-contains queries 所以 'AND' 排序查询必须在客户端完成)

以这种方式使用数组将允许它们针对并发写入进行优化,这很好!还没有测试它是否支持批处理请求(文档没有说),但我敢打赌它是支持的,因为它是官方解决方案。


用法:

collection("collectionPath").
    where("searchTermsArray", "array-contains", "term").get()

迟到的答案,但对于仍在寻找答案的任何人来说,假设我们有一个用户集合,并且在该集合的每个文档中我们都有一个“用户名”字段,所以如果想找到用户名所在的文档以“al”开头,我们可以做类似

的事情
FirebaseFirestore.getInstance()
    .collection("users")
    .whereGreaterThanOrEqualTo("username", "al")

我刚遇到这个问题,想出了一个非常简单的解决方案。

String search = "ca";
Firestore.instance.collection("categories").orderBy("name").where("name",isGreaterThanOrEqualTo: search).where("name",isLessThanOrEqualTo: search+"z")

isGreaterThanOrEqualTo 让我们过滤掉搜索的开头,并通过在 isLessThanOrEqualTo 的末尾添加 "z" 来限制我们的搜索,使其不会滚动到下一个文档。

我实际上认为在 Firestore 中执行此操作的最佳解决方案是将所有子字符串放入一个数组中,然后只执行 array_contains 查询。这允许您进行子字符串匹配。存储所有子字符串有点矫枉过正,但如果您的搜索词很短,这是非常非常合理的。

我同意@Kuba 的回答,但是,它仍然需要添加一个小的更改才能完美地用于按前缀搜索。这对我有用

用于搜索以姓名 queryText

开头的记录
collectionRef
    .where('name', '>=', queryText)
    .where('name', '<=', queryText+ '\uf8ff')

查询中使用的字符 \uf8ff 是 Unicode 范围内的一个非常高的代码点(它是一个 Private Usage Area [PUA] 代码)。因为它在 Unicode 中的大多数常规字符之后,查询匹配所有以 queryText.

开头的值

如果您不想使用像 Algolia 这样的 third-party 服务,Firebase Cloud Functions 是一个不错的选择。您可以创建一个可以接收输入参数的函数,处理记录 server-side 然后 return 符合您条件的记录。

编辑 05/2021:

Google Firebase 现在有一个扩展来实现 Search with Algolia。 Algolia 是一个全文搜索平台,具有广泛的功能列表。您需要在 Firebase 上有一个“Blaze”计划,并且有与 Algolia 查询相关的费用,但这是我推荐的生产应用程序方法。如果您更喜欢免费的基本搜索,请参阅下面我的原始答案。

https://firebase.google.com/products/extensions/firestore-algolia-search https://www.algolia.com

原始答案:

所选答案仅适用于精确搜索,不是自然的用户搜索行为(在“Joe ate an apple today”中搜索“apple”无效)。

我觉得上面Dan Fein的回答应该排名靠前。如果您要搜索的字符串数据很短,您可以将字符串的所有子字符串保存在文档中的一个数组中,然后使用 Firebase 的 array_contains 查询搜索该数组。 Firebase 文档限制为 1 MiB(1,048,576 字节)(Firebase Quotas and Limits) ,即文档中保存了大约 100 万个字符(我认为 1 个字符 ~= 1 个字节)。只要您的文档不接近 100 万大关,就可以存储子字符串。

搜索用户名的示例:

第 1 步:将以下字符串扩展添加到您的项目中。这使您可以轻松地将字符串分解为子字符串。 (I found this here).

extension String {

var length: Int {
    return count
}

subscript (i: Int) -> String {
    return self[i ..< i + 1]
}

func substring(fromIndex: Int) -> String {
    return self[min(fromIndex, length) ..< length]
}

func substring(toIndex: Int) -> String {
    return self[0 ..< max(0, toIndex)]
}

subscript (r: Range<Int>) -> String {
    let range = Range(uncheckedBounds: (lower: max(0, min(length, r.lowerBound)),
                                        upper: min(length, max(0, r.upperBound))))
    let start = index(startIndex, offsetBy: range.lowerBound)
    let end = index(start, offsetBy: range.upperBound - range.lowerBound)
    return String(self[start ..< end])
}

第 2 步:当您存储用户名时,还将此函数的结果作为数组存储在同一个文档中。这将创建原始文本的所有变体并将它们存储在一个数组中。例如,文本输入“Apple”将创建以下数组:["a"、"p"、"p"、"l"、"e"、"ap"、"pp"、"pl"、"le ", "app", "ppl", "ple", "appl", "pple", "apple"],它应该包含用户可能输入的所有搜索条件。如果你想要所有结果,你可以将 maximumStringSize 保留为 nil,但是,如果有长文本,我建议在文档大小变得太大之前对其进行限制 - 大约 15 对我来说很好(大多数人无论如何都不会搜索长短语).

func createSubstringArray(forText text: String, maximumStringSize: Int?) -> [String] {
    
    var substringArray = [String]()
    var characterCounter = 1
    let textLowercased = text.lowercased()
    
    let characterCount = text.count
    for _ in 0...characterCount {
        for x in 0...characterCount {
            let lastCharacter = x + characterCounter
            if lastCharacter <= characterCount {
                let substring = textLowercased[x..<lastCharacter]
                substringArray.append(substring)
            }
        }
        characterCounter += 1
        
        if let max = maximumStringSize, characterCounter > max {
            break
        }
    }
    
    print(substringArray)
    return substringArray
}

第 3 步:您可以使用 Firebase 的 array_contains 功能!

[yourDatabasePath].whereField([savedSubstringArray], arrayContains: searchText).getDocuments....

我相信 Firebase 很快就会推出“string-contains”来捕获字符串中的任何索引 [i] startAt... 但 我研究了网络并发现了其他人想到的这个解决方案 像这样设置你的数据

state = { title: "Knitting" };
// ...
const c = this.state.title.toLowerCase();

var array = [];
for (let i = 1; i < c.length + 1; i++) {
  array.push(c.substring(0, i));
}

firebase
  .firestore()
  .collection("clubs")
  .doc(documentId)
  .update({
    title: this.state.title,
    titleAsArray: array
  });

这样查询

firebase.firestore()
    .collection("clubs")
    .where(
        "titleAsArray",
        "array-contains",
        this.state.userQuery.toLowerCase()
    )

使用 Firestore,您可以实现全文搜索,但与其他方式相比,它的读取成本仍然更高,而且您还需要以特定方式输入和索引数据,因此在这种方法中,您可以使用 firebase 云函数进行标记化,然后对输入文本进行哈希处理,同时选择满足以下条件的线性哈希函数 h(x) - 如果 x < y < z then h(x) < h (y) < h(z)。对于标记化,您可以选择一些轻量级的 NLP 库,以将函数的冷启动时间保持在较低水平,从而可以从句子中去除不必要的单词。然后,您可以 运行 在 Firestore 中使用小于和大于运算符进行查询。 在存储数据的同时,您必须确保在存储文本之前对文本进行哈希处理,并且还存储纯文本,就像您更改纯文本一样,哈希值也会更改。

全文搜索、相关搜索和三字母搜索!

更新 - 2/17/21 - 我创建了几个新的全文搜索选项。

详情见Fireblog.io


另外,旁注,dgraph 现在有用于实时的 websockets ......哇,从来没有见过,真是太好了! Slash Dgraph - Amazing!


--原Post--

这里有几点说明:

1.) \uf8ff~

的工作方式相同

2.) 您可以使用 where 子句或开始结束子句:

ref.orderBy('title').startAt(term).endAt(term + '~');

完全一样
ref.where('title', '>=', term).where('title', '<=', term + '~');

3.) 不,如果你在每个组合中反转 startAt()endAt() 是行不通的,但是,你可以获得相同的结果通过创建第二个反向搜索字段,并合并结果。

示例:首先,您必须在创建字段时保存字段的反转版本。像这样:

// collection
const postRef = db.collection('posts')

async function searchTitle(term) {

  // reverse term
  const termR = term.split("").reverse().join("");

  // define queries
  const titles = postRef.orderBy('title').startAt(term).endAt(term + '~').get();
  const titlesR = postRef.orderBy('titleRev').startAt(termR).endAt(termR + '~').get();

  // get queries
  const [titleSnap, titlesRSnap] = await Promise.all([
    titles,
    titlesR
  ]);
  return (titleSnap.docs).concat(titlesRSnap.docs);
}

有了这个,你可以搜索字符串字段的 last 字母和 first,而不是随机的中间字母或组字母。这更接近于期望的结果。然而,当我们想要随机的中间字母或单词时,这并不能真正帮助我们。此外,请记住将所有内容保存为小写,或用于搜索的小写副本,这样大小写就不会成为问题。

4.)如果你只有几个字,Ken Tan's Method会做你想做的一切,或者至少在你稍微修改之后。然而,仅仅一段文本,你将以指数方式创建超过 1MB 的数据,这比 firestore 的文档大小限制更大(我知道,我测试过)。

5.) 如果你可以将 array-contains(或某种形式的数组)与 \uf8ff 技巧结合起来,您可能会进行未达到限制的可行搜索。我尝试了每一种组合,即使是地图,也没有成功。任何人都明白这一点,post 在这里。

6.) 如果你必须远离 ALGOLIA 和 ELASTIC SEARCH,我一点也不怪你,你总是可以使用 mySQL,postSQL,或 Google Cloud 上的 neo4Js。它们都很容易设置 3,并且有免费套餐。您将有一个云函数来保存数据 onCreate() 和另一个 onCall() 函数来搜索数据。简单... 左右。为什么不直接切换到 mySQL 呢?当然是实时数据!当有人用 websocks 写 DGraph 来获取实时数据时,算我一个!

Algolia 和 ElasticSearch 被构建为仅用于搜索的数据库,因此没有什么比这更快的了……但你要为此付出代价。 Google,你为什么要带我们离开Google,你不跟MongoDB不SQL允许搜索吗?

这对我来说非常有用,但可能会导致性能问题。

查询 firestore 时执行此操作:

   Future<QuerySnapshot> searchResults = collectionRef
        .where('property', isGreaterThanOrEqualTo: searchQuery.toUpperCase())
        .getDocuments();

在您的 FutureBuilder 中执行此操作:

    return FutureBuilder(
          future: searchResults,
          builder: (context, snapshot) {           
            List<Model> searchResults = [];
            snapshot.data.documents.forEach((doc) {
              Model model = Model.fromDocumet(doc);
              if (searchQuery.isNotEmpty &&
                  !model.property.toLowerCase().contains(searchQuery.toLowerCase())) {
                return;
              }

              searchResults.add(model);
            })
   };

截至今天(2020 年 8 月 18 日),专家建议基本上有 3 种不同的解决方法作为问题的答案。

我都试过了。我认为记录我对他们每个人的经历可能会有用。

Method-A:使用:(dbField ">=" searchString) & (dbField "<=" searchString + "\uf8ff")

由@Kuba 和@Ankit Prajapati 建议

.where("dbField1", ">=", searchString)
.where("dbField1", "<=", searchString + "\uf8ff");

A.1 Firestore 查询只能对单个字段执行范围过滤器(>、<、>=、<=)。不支持在多个字段上使用范围过滤器的查询。通过使用此方法,您不能在数据库的任何其他字段中使用范围运算符,例如日期字段。

A.2。此方法不适用于同时在多个字段中进行搜索。例如,您无法检查搜索字符串是否在任何字段(姓名、备注和地址)中。

Method-B:对映射中的每个条目使用带有“true”的搜索字符串 MAP,并在查询中使用“==”运算符

由@Gil Gilbert 建议

document1 = {
  'searchKeywordsMap': {
    'Jam': true,
    'Butter': true,
    'Muhamed': true,
    'Green District': true,
    'Muhamed, Green District': true,
  }
}

.where(`searchKeywordsMap.${searchString}`, "==", true);

B.1 显然,这种方法在每次将数据保存到数据库时都需要额外的处理,更重要的是,需要额外的space来存储搜索字符串的映射。

B.2 如果一个Firestore查询只有一个像上面那样的条件,那么不需要事先创建索引。在这种情况下,此解决方案可以正常工作。

B.3 然而,如果查询有另一个条件,例如(status === "active",) 用户输入的每个“搜索字符串”似乎都需要一个索引。换句话说,如果一个用户搜索“Jam”而另一个用户搜索“Butter”,则应该预先为字符串“Jam”创建一个索引,为“Butter”等创建另一个索引。除非你能预测所有可能的情况用户的搜索字符串,这不起作用 - 如果查询有其他条件!

.where(searchKeywordsMap["Jam"], "==", true); // requires an index on searchKeywordsMap["Jam"]
.where("status", "==", "active");

**Method-C:使用搜索字符串数组,以及“array-contains”运算符

由@Albert Renshaw 建议并由@Nick Carducci 演示

document1 = {
  'searchKeywordsArray': [
    'Jam',
    'Butter',
    'Muhamed',
    'Green District',
    'Muhamed, Green District',
  ]
}

.where("searchKeywordsArray", "array-contains", searchString); 

C.1 与Method-B类似,这种方法在每次保存数据到db时都需要额外的处理,更重要的是,需要额外的space来存储搜索字符串数组。

C.2 Firestore 查询在一个复合查询中最多可以包含一个“array-contains”或“array-contains-any”子句。

一般限制:

  1. None 这些解决方案似乎支持搜索部分字符串。例如,如果数据库字段包含“1 Peter St, Green District”,则无法搜索字符串“strict.”。
  2. 几乎不可能涵盖预期搜索字符串的所有可能组合。例如,如果数据库字段包含“1 Mohamed St, Green District”,您可能无法搜索字符串“Green Mohamed”,该字符串的单词顺序与数据库中使用的顺序不同领域。

没有适合所有人的解决方案。每个解决方法都有其局限性。我希望以上信息可以在这些解决方法之间的选择过程中对您有所帮助。

有关 Firestore 查询条件的列表,请查看文档 https://firebase.google.com/docs/firestore/query-data/queries

我没试过https://fireblog.io/blog/post/firestore-full-text-search,这是@Jonathan 推荐的

以下代码片段接受用户的输入并从输入的开始获取数据。

示例数据:

在 Firebase 下 Collection 'Users'

用户 1:{姓名:'Ali',年龄:28},

用户 2:{姓名:'Khan',年龄:30},

用户 3:{姓名:'Hassan',年龄:26},

用户 4:{姓名:'Adil',年龄:32}

TextInput: A

结果:

{姓名:'Ali',年龄:28},

{姓名:'Adil',年龄:32}

let timer;

// method called onChangeText from TextInput

const textInputSearch = (text) => {

const inputStart = text.trim();
let lastLetterCode = inputStart.charCodeAt(inputStart.length-1);
lastLetterCode++;
const newLastLetter = String.fromCharCode(lastLetterCode);
const inputEnd = inputStart.slice(0,inputStart.length-1) + lastLetterCode;

clearTimeout(timer);

timer = setTimeout(() => {
    firestore().collection('Users')
        .where('name', '>=', inputStart)
        .where('name', '<', inputEnd)
        .limit(10)
        .get()
        .then(querySnapshot => {
            const users = [];
                querySnapshot.forEach(doc => {
                    users.push(doc.data());
                })
            setUsers(users); //  Setting Respective State
        });
    }, 1000);

};

与@nicksarno 相同,但代码更优美,不需要任何扩展:

步骤 1

func getSubstrings(from string: String, maximumSubstringLenght: Int = .max) -> [Substring] {
    let string = string.lowercased()
    let stringLength = string.count
    let stringStartIndex = string.startIndex
    var substrings: [Substring] = []
    for lowBound in 0..<stringLength {
        for upBound in lowBound..<min(stringLength, lowBound+maximumSubstringLenght) {
            let lowIndex = string.index(stringStartIndex, offsetBy: lowBound)
            let upIndex = string.index(stringStartIndex, offsetBy: upBound)
            substrings.append(string[lowIndex...upIndex])
        }
    }
    return substrings
}

步骤 2

let name = "Lorenzo"
ref.setData(["name": name, "nameSubstrings": getSubstrings(from: name)])

步骤 3

Firestore.firestore().collection("Users")
  .whereField("nameSubstrings", arrayContains: searchText)
  .getDocuments...

2021 年更新

从其他答案中吸取了一些东西。这个包括:

  • 使用拆分(充当 OR)的多词搜索
  • 使用平面的多键搜索

在区分大小写方面有点受限,您可以通过以大写形式存储重复的属性来解决这个问题。例如:query.toUpperCase() user.last_name_upper


// query: searchable terms as string

let users = await searchResults("Bob Dylan", 'users');

async function searchResults(query = null, collection = 'users', keys = ['last_name', 'first_name', 'email']) {

    let querySnapshot = { docs : [] };

    try {
        if (query) {
            let search = async (query)=> {
                let queryWords = query.trim().split(' ');
                return queryWords.map((queryWord) => keys.map(async (key) =>
                    await firebase
                        .firestore()
                        .collection(collection)
                        .where(key, '>=', queryWord)
                        .where(key, '<=', queryWord +  '\uf8ff')
                        .get())).flat();
            }

            let results = await search(query);

            await (await Promise.all(results)).forEach((search) => {
                querySnapshot.docs = querySnapshot.docs.concat(search.docs);
            });
        } else {
            // No query
            querySnapshot = await firebase
                .firestore()
                .collection(collection)
                // Pagination (optional)
                // .orderBy(sortField, sortOrder)
                // .startAfter(startAfter)
                // .limit(perPage)
                .get();
        }
    } catch(err) {
        console.log(err)
    }

    // Appends id and creates clean Array
    const items = [];
    querySnapshot.docs.forEach(doc => {
        let item = doc.data();
        item.id = doc.id;
        items.push(item);
    });

    // Filters duplicates
    return items.filter((v, i, a) => a.findIndex(t => (t.id === v.id)) === i);
}

注意:Firebase 调用的次数等于查询字符串中的单词数 * 您正在搜索的键数。

Firebase 建议使用 Algolia 或 ElasticSearch 进行全文搜索,但更便宜的选择可能是 MongoDB。最便宜的集群(约 10 美元/月)允许您为全文建立索引。

我用了八卦,就像乔纳森说的那样。

三字母组是存储在数据库中以帮助搜索的 3 个字母组。所以如果我有用户数据并且我想查询 'trum' for donald trump 我必须以这种方式存储它

我只是想这样回忆一下

 onPressed: () {
      //LET SAY YOU TYPE FOR 'tru' for trump
      List<String> search = ['tru', 'rum'];
      Future<QuerySnapshot> inst = FirebaseFirestore.instance
          .collection("users")
          .where('trigram', arrayContainsAny: search)
          .get();
      print('result=');
      inst.then((value) {
        for (var i in value.docs) {
          print(i.data()['name']);
        }
      });

无论如何都会得到正确的结果

Typesense 服务为 Firebase Cloud Firestore 数据库提供子字符串搜索。

https://typesense.org/docs/guide/firebase-full-text-search.html

下面是我项目中typesense集成的相关代码

lib/utils/typesense.飞镖

import 'dart:convert';

import 'package:flutter_instagram_clone/model/PostModel.dart';
import 'package:http/http.dart' as http;

class Typesense {
  static String baseUrl = 'http://typesense_server_ip:port/';
  static String apiKey = 'xxxxxxxx'; // your Typesense API key
  static String resource = 'collections/postData/documents/search';

  static Future<List<PostModel>> search(String searchKey, int page, {int contentType=-1}) async {
    if (searchKey.isEmpty) return [];

    List<PostModel> _results = [];

    var header = {'X-TYPESENSE-API-KEY': apiKey};
    String strSearchKey4Url = searchKey.replaceFirst('#', '%23').replaceAll(' ', '%20');
    String url = baseUrl +
        resource +
        '?q=${strSearchKey4Url}&query_by=postText&page=$page&sort_by=millisecondsTimestamp:desc&num_typos=0';
    if(contentType==0)
    {
      url += "&filter_by=isSelling:false";
    } else if(contentType == 1)
    {
      url += "&filter_by=isSelling:true";
    }

    var response = await http.get(Uri.parse(url), headers: header);

    var data = json.decode(response.body);
    for (var item in data['hits']) {
      PostModel _post = PostModel.fromTypeSenseJson(item['document']);

      if (searchKey.contains('#')) {
        if (_post.postText.toLowerCase().contains(searchKey.toLowerCase()))
          _results.add(_post);
      } else {
        _results.add(_post);
      }
    }

    print(_results.length);
    return _results;
  }

  static Future<List<PostModel>> getHubPosts(String searchKey, int page,
      {List<String>? authors, bool? isSelling}) async {
    List<PostModel> _results = [];

    var header = {'X-TYPESENSE-API-KEY': apiKey};

    String filter = "";
    if (authors != null || isSelling != null) {
      filter += "&filter_by=";

      if (isSelling != null) {
        filter += "isSelling:$isSelling";
        if (authors != null && authors.isNotEmpty) {
          filter += "&&";
        }
      }

      if (authors != null && authors.isNotEmpty) {
        filter += "authorID:$authors";
      }
    }

    String url = baseUrl +
        resource +
        '?q=${searchKey.replaceFirst('#', '%23')}&query_by=postText&page=$page&sort_by=millisecondsTimestamp:desc&num_typos=0$filter';

    var response = await http.get(Uri.parse(url), headers: header);

    var data = json.decode(response.body);
    for (var item in data['hits']) {
      PostModel _post = PostModel.fromTypeSenseJson(item['document']);
      _results.add(_post);
    }

    print(_results.length);

    return _results;
  }
}

lib/services/hubDetailsService.飞镖

import 'package:flutter/material.dart';
import 'package:flutter_instagram_clone/model/PostModel.dart';
import 'package:flutter_instagram_clone/utils/typesense.dart';

class HubDetailsService with ChangeNotifier {
  String searchKey = '';
  List<String>? authors;
  bool? isSelling;
  int nContentType=-1;


  bool isLoading = false;
  List<PostModel> hubResults = [];
  int _page = 1;
  bool isMore = true;
  bool noResult = false;

  Future initSearch() async {
    isLoading = true;
    isMore = true;
    noResult = false;
    hubResults = [];
    _page = 1;
    List<PostModel> _results = await Typesense.search(searchKey, _page, contentType: nContentType);
    for(var item in _results) {
      hubResults.add(item);
    }
    isLoading = false;
    if(_results.length < 10) isMore = false;
    if(_results.isEmpty) noResult = true;
    notifyListeners();
  }

  Future nextPage() async {
    if(!isMore) return;
    _page++;
    List<PostModel> _results = await Typesense.search(searchKey, _page);
    hubResults.addAll(_results);
    if(_results.isEmpty) {
      isMore = false;
    }
    notifyListeners();
  }

  Future refreshPage() async {
    isLoading = true;
    notifyListeners();
    await initSearch();
    isLoading = false;
    notifyListeners();
  }

  Future search(String _searchKey) async {
    isLoading = true;
    notifyListeners();
    searchKey = _searchKey;
    await initSearch();
    isLoading = false;
    notifyListeners();
  }
}

lib/ui/hub/hubDetailsScreen.飞镖

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_instagram_clone/constants.dart';
import 'package:flutter_instagram_clone/main.dart';
import 'package:flutter_instagram_clone/model/MessageData.dart';
import 'package:flutter_instagram_clone/model/SocialReactionModel.dart';
import 'package:flutter_instagram_clone/model/User.dart';
import 'package:flutter_instagram_clone/model/hubModel.dart';
import 'package:flutter_instagram_clone/services/FirebaseHelper.dart';
import 'package:flutter_instagram_clone/services/HubService.dart';
import 'package:flutter_instagram_clone/services/helper.dart';
import 'package:flutter_instagram_clone/services/hubDetailsService.dart';
import 'package:flutter_instagram_clone/ui/fullScreenImageViewer/FullScreenImageViewer.dart';
import 'package:flutter_instagram_clone/ui/home/HomeScreen.dart';
import 'package:flutter_instagram_clone/ui/hub/editHubScreen.dart';
import 'package:provider/provider.dart';
import 'package:smooth_page_indicator/smooth_page_indicator.dart';

class HubDetailsScreen extends StatefulWidget {

  final HubModel hub;
  HubDetailsScreen(this.hub);

  @override
  _HubDetailsScreenState createState() => _HubDetailsScreenState();
}

class _HubDetailsScreenState extends State<HubDetailsScreen> {

  late HubDetailsService _service;
  List<SocialReactionModel?> _reactionsList = [];
  final fireStoreUtils = FireStoreUtils();
  late Future<List<SocialReactionModel>> _myReactions;
  final scrollController = ScrollController();
  bool _isSubLoading = false;


  @override
  void initState() {
    // TODO: implement initState
    super.initState();
    _service = Provider.of<HubDetailsService>(context, listen: false);
    print(_service.isLoading);
    init();
  }

  init() async {

    _service.searchKey = "";

    if(widget.hub.contentWords.length>0)
    {
      for(var item in widget.hub.contentWords) {
        _service.searchKey += item + " ";
      }
    }
    switch(widget.hub.contentType) {
      case 'All':
        break;
      case 'Marketplace':
        _service.isSelling = true;
        _service.nContentType = 1;
        break;
      case 'Post Only':
        _service.isSelling = false;
        _service.nContentType = 0;
        break;
      case 'Keywords':
        break;
    }

    for(var item in widget.hub.exceptWords) {
      if(item == 'Marketplace') {
        _service.isSelling = _service.isSelling != null?true:false;
      } else {
        _service.searchKey += "-" + item + "";
      }
    }

    if(widget.hub.fromUserType == 'Followers') {
      List<User> _followers = await fireStoreUtils.getFollowers(MyAppState.currentUser!.userID);

      _service.authors = [];
      for(var item in _followers)
        _service.authors!.add(item.userID);

    }

    if(widget.hub.fromUserType == 'Selected') {
      _service.authors = widget.hub.fromUserIds;
    }

    _service.initSearch();

    _myReactions = fireStoreUtils.getMyReactions()
      ..then((value) {
        _reactionsList.addAll(value);
      });

    scrollController.addListener(pagination);
  }


  void pagination(){
    if(scrollController.position.pixels ==
        scrollController.position.maxScrollExtent) {
      _service.nextPage();
    }
  }

  @override
  Widget build(BuildContext context) {

    Provider.of<HubDetailsService>(context);

    PageController _controller = PageController(
      initialPage: 0,
    );

    return Scaffold(
      backgroundColor: Colors.white,
      body: RefreshIndicator(
        onRefresh: () async {
          _service.refreshPage();
        },
        child: CustomScrollView(
          controller: scrollController,
          slivers: [
            SliverAppBar(
              centerTitle: false,
              expandedHeight: MediaQuery.of(context).size.height * 0.25,
              pinned: true,
              backgroundColor: Colors.white,
              title: Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  InkWell(
                    onTap: (){
                      Navigator.pop(context);
                    },
                    child: Container(
                      width: 35, height: 35,
                      decoration: BoxDecoration(
                          color: Colors.white,
                          borderRadius: BorderRadius.circular(20)
                      ),
                      child: Center(
                        child: Icon(Icons.arrow_back),
                      ),
                    ),
                  ),

                  if(widget.hub.user.userID == MyAppState.currentUser!.userID)
                  InkWell(
                    onTap: () async {
                      var _hub = await push(context, EditHubScreen(widget.hub));

                      if(_hub != null) {
                        Navigator.pop(context, true);
                      }

                    },
                    child: Container(
                      width: 35, height: 35,
                      decoration: BoxDecoration(
                          color: Colors.white,
                          borderRadius: BorderRadius.circular(20)
                      ),
                      child: Center(
                        child: Icon(Icons.edit, color: Colors.black, size: 20,),
                      ),
                    ),
                  ),
                ],

              ),
              automaticallyImplyLeading: false,
              flexibleSpace: FlexibleSpaceBar(
                  collapseMode: CollapseMode.pin,
                  background: Container(color: Colors.grey,
                    child: Stack(
                      children: [
                        PageView.builder(
                            controller: _controller,
                            itemCount: widget.hub.medias.length,
                            itemBuilder: (context, index) {
                              Url postMedia = widget.hub.medias[index];
                              return GestureDetector(
                                  onTap: () => push(
                                      context,
                                      FullScreenImageViewer(
                                          imageUrl: postMedia.url)),
                                  child: displayPostImage(postMedia.url));
                            }),
                        if (widget.hub.medias.length > 1)
                          Padding(
                            padding: const EdgeInsets.only(bottom: 30.0),
                            child: Align(
                              alignment: Alignment.bottomCenter,
                              child: SmoothPageIndicator(
                                controller: _controller,
                                count: widget.hub.medias.length,
                                effect: ScrollingDotsEffect(
                                    dotWidth: 6,
                                    dotHeight: 6,
                                    dotColor: isDarkMode(context)
                                        ? Colors.white54
                                        : Colors.black54,
                                    activeDotColor: Color(COLOR_PRIMARY)),
                              ),
                            ),
                          ),
                      ],
                    ),
                  )
              ),
            ),

            _service.isLoading?
            SliverFillRemaining(
              child: Center(
                child: CircularProgressIndicator(),
              ),
            ):
            SliverList(
              delegate: SliverChildListDelegate([

                if(widget.hub.userId != MyAppState.currentUser!.userID)
                  _isSubLoading?
                  Center(
                    child: Padding(
                      padding: EdgeInsets.all(5),
                      child: CircularProgressIndicator(),
                    ),
                  ):
                  Padding(
                    padding: EdgeInsets.symmetric(horizontal: 5),
                    child: widget.hub.shareUserIds.contains(MyAppState.currentUser!.userID)?
                    ElevatedButton(
                      onPressed: () async {
                        setState(() {
                          _isSubLoading = true;
                        });

                        await Provider.of<HubService>(context, listen: false).unsubscribe(widget.hub);

                        setState(() {
                          _isSubLoading = false;
                          widget.hub.shareUserIds.remove(MyAppState.currentUser!.userID);
                        });
                      },
                      style: ElevatedButton.styleFrom(
                          primary: Colors.red
                      ),
                      child: Text(
                        "Unsubscribe",
                      ),
                    ):
                    ElevatedButton(
                      onPressed: () async {
                        setState(() {
                          _isSubLoading = true;
                        });

                        await Provider.of<HubService>(context, listen: false).subscribe(widget.hub);

                        setState(() {
                          _isSubLoading = false;
                          widget.hub.shareUserIds.add(MyAppState.currentUser!.userID);
                        });
                      },
                      style: ElevatedButton.styleFrom(
                          primary: Colors.green
                      ),
                      child: Text(
                        "Subscribe",
                      ),
                    ),
                  ),

                Padding(
                  padding: EdgeInsets.all(15,),
                  child: Text(
                    widget.hub.name,
                    style: TextStyle(
                        color: Colors.black,
                        fontSize: 18,
                        fontWeight: FontWeight.bold
                    ),
                  ),
                ),

                ..._service.hubResults.map((e) {
                  if(e.isAuction && (e.auctionEnded || DateTime.now().isAfter(e.auctionEndTime??DateTime.now()))) {
                    return Container();
                  }
                  return PostWidget(post: e);
                }).toList(),

                if(_service.noResult)
                  Padding(
                    padding: EdgeInsets.all(20),
                    child: Text(
                      'No results for this hub',
                      style: TextStyle(
                          fontSize: 18,
                          fontWeight: FontWeight.bold
                      ),
                    ),
                  ),

                if(_service.isMore)
                  Center(
                    child: Container(
                      padding: EdgeInsets.all(5),
                      child: CircularProgressIndicator(),
                    ),
                  )

              ]),
            )
          ],
        ),
      )
    );
  }
}