diff --git a/.claude/skills/openapi-migration/SKILL.md b/.claude/skills/openapi-migration/SKILL.md index df25e3d946..4447b9e876 100644 --- a/.claude/skills/openapi-migration/SKILL.md +++ b/.claude/skills/openapi-migration/SKILL.md @@ -205,6 +205,8 @@ a public signature. [Persisted models](#persisted-models-the-dataserializable-codec)). A model stored only as a table row, mapped column by column like `Poll` in `polls`, needs no codec. - **Which request enums need a hand-written public type** (rule 6). +- **Whether v10 has a `copyWith`.** If it has one, run `scripts/copywith_audit.py ` before deleting it; + [Replacing a v10 `copyWith`](#replacing-a-v10-copywith) says what to do with the report. **Per method, decide what it returns:** @@ -339,9 +341,13 @@ incrementally, not for us. Three data-shape traps while mapping: -- **`custom` vs `extraData`** (channel / message / user features). The wire puts extra fields in `custom`; - hand-written models flatten them into `extraData`. Write that promotion explicitly, including which keys are - excluded. +- **The model's `custom` is the wire's `custom`, verbatim** (README rule 9). A migrated model names it `custom` + — `extraData` on one whose group has not run — and the mapper copies the generated map into it and sends it + back as `custom`; never filter either. A typed wire field our model lacks is added as a field, with + approval, or left out — never parked in `extraData` behind a getter, as `ChannelModel.hidden` and `User.name` + still are until groups 11 and 09 promote them. A v1 decoder splits flat JSON by a key list — `topLevelFields` + while the model still has JSON, a private list beside the converter once it does not — and a v1 writer leaves a + custom entry named like a key out so it cannot overwrite the key. - **Units on anything that takes a `Duration`.** The wire carries a bare integer and the field name rarely says in what. Read the Go payload type: `commonpayloads.DurationInMinutes` and `DurationMilliseconds` sit on sibling endpoints in the same feature — `ban.timeout` and `mute.timeout` are minutes, `muteChannel.expiration` is @@ -457,6 +463,31 @@ List>? _membersToData(List? members) => `.g.dart` and `.freezed.dart` files of the models you changed; `--build-filter` deletes every generated output outside the filter, so never use it. +### Replacing a v10 `copyWith` + +A model moving onto `@freezed` gives up its hand-written `copyWith` for the generated one, and most of the +difference compiles cleanly: `null` clears a nullable field where v10 kept it, `null` re-runs a constructor default +(`id ?? Uuid().v4()`, `createdAt ?? DateTime.now()`), and a constructor parameter with no field of the same name +and type is not generated at all. [`copywith-audit.md`](copywith-audit.md) has the full table and the procedure; +`scripts/copywith_audit.py ` runs it, on a clean tree, and lists every call site in lib and tests whose +behaviour or compilation changes. + +- **Handle every site the report lists** (README rule 2): a compile break gets a local to carry flow analysis, or + a `?? model.field`; a site tagged "v10 kept on null; check" is rewritten as an explicit merge unless the value + is only ever null when the field already is (`Poll.withTranslationsOf`) or the clear is intended; a site tagged + "v10 sentinel, same result" needs nothing. +- **A verdict line means freezed cannot replace this method as it stands.** Keep `@Freezed(copyWith: false)` with + the hand-written method, or reshape the constructor so every parameter is a field of the same name and type, + which is a public API change of its own. `Message` is the known case. +- **Record the semantics change** in the CHANGELOG and the guide, listing the model's fields under the rows of the + audit's table that apply. A model with no v10 `copyWith` gains new API, which is not a break. +- **Mind what freezed counts as data.** For a class with a hand-written constructor, it puts every instance field + in `==`, `hashCode` and `toString`, private fields and `late final` caches included. + - A cache derived only from fields already compared (`Poll.ownVotes`, from `ownVotesAndAnswers`) changes no + result, so it can stay `late final`. + - A field v10 set in the constructor's initializer list from another field (`PollVote.isAnswer`) becomes a + getter. + ### Documenting the public surface Write these as you write the code. `public_member_api_docs` only checks a doc *exists*, so a placeholder survives @@ -488,6 +519,7 @@ Budget for this: on a typical feature it is most of the diff, and none of it is - **Stub each generated response with every field our model carries,** each with a distinct value, and compare the whole returned envelope, so a field the mapper drops or swaps fails an assertion. Give every method a failure test too. +- **Flip the tests that pinned a v10 `copyWith`'s `null` handling** to freezed's behaviour; the audit lists them. - **Test each temporary converter through its parent's `fromJson`/`toJson`.** - **Test each `@DataSerializable` model's stored format:** pin `toData()` to a literal map, so a rename that changes the stored keys fails; round-trip `fromData(toData())`; cover null versus empty for nullable lists. Extend the diff --git a/.claude/skills/openapi-migration/copywith-audit.md b/.claude/skills/openapi-migration/copywith-audit.md new file mode 100644 index 0000000000..bf491219c9 --- /dev/null +++ b/.claude/skills/openapi-migration/copywith-audit.md @@ -0,0 +1,88 @@ +# copyWith audit: moving a model to freezed's copyWith + +A model moving onto `@freezed` gives up its hand-written `copyWith` for the generated one. The two treat `null` +differently, freezed can't generate every parameter a hand-written method takes, and most of the difference +compiles cleanly. Run this audit per model before deleting the old method. It lists every call site, in lib and in +tests, whose behaviour or compilation changes, and tells you when freezed's `copyWith` can't replace the model's +at all. + +## What changes + +| Parameter | v10 hand-written | freezed | How it shows up | +| --- | --- | --- | --- | +| Non-nullable field (`String name`) | `String? name`, null keeps | `String name` | Compile error where a nullable value is passed | +| Nullable field, no sentinel (`String? description`) | null keeps | null clears | Silent: same code, field now wiped | +| Nullable field with `_nullConst` sentinel | null clears | null clears | No change | +| Nullable constructor parameter on a non-nullable field (`String? id` → `id ?? Uuid().v4()`) | null keeps | null runs the constructor default | Silent: a new id or `DateTime.now()` | +| Constructor parameter with no field of the same name (a getter, a private field, a renamed field) | accepted | not generated | Compile error at every call passing it | +| Constructor parameter whose type differs from its field's (`String type` → `MessageType type`) | the field's type | the parameter's type | Compile error where the field's type is passed | + +freezed builds `copyWith` from the class's constructor, and keeps a parameter only when a field (not a getter) has +the same name. That is why the last two rows exist. + +## Run it + +One model per run, named by its class, from anywhere in the repo after `melos bootstrap`: + +```bash +python3 .claude/skills/openapi-migration/scripts/copywith_audit.py Poll +``` + +When two packages declare the class, pass the file: `packages/stream_chat/lib/src/core/models/poll.dart:Poll`. By +default it analyzes every bootstrapped package; `--packages packages/stream_chat packages/stream_chat_flutter …` +narrows it. A run over five packages takes about half a minute. + +The model can be on `@Freezed(copyWith: false)` already, or not on freezed at all. For the second, the probe adds +`@freezed`, `with _$Model`, the `part` and the import for the run only. Either way it deletes the hand-written +`copyWith`, regenerates with the `.fvmrc`-pinned SDK, and analyzes twice: + +1. As generated. Every error is a compile break. +2. With the `?` dropped from the generated `copyWith` interface. Every new error is a call site passing a value + that may be null to a nullable parameter, which is exactly where "keeps" became "clears". + +**What it touches.** Only the model's file and the generated `*.freezed.dart` / `*.g.dart` files of the model's +package, all restored at the end, even on failure. It refuses to start while any of those has uncommitted changes. +Edits anywhere else, including while it runs, are left alone. + +## Read the report + +A verdict line appears when freezed's `copyWith` can't replace the model's as it stands, because parameters are +missing or retyped (`Message` is the example: `createdAt` is a getter over server and local timestamps, +`quotedMessageId` a private field with a fallback, `type` a `String` turned into a `MessageType`). Then either keep +`@Freezed(copyWith: false)` with the hand-written method, or reshape the constructor so every parameter is a field +of the same name and type, which is a public API change of its own. The sections below the verdict still show what +each option would cost. + +Nullable argument sites are tagged: + +* **v10 sentinel, same result**: no action. +* **v10 kept on null; check**: decide whether the value can be null at runtime while the field is set. If it is + only ever null when the field already is (`nameI18n: nameI18n ?? fallback`), the result is unchanged. Otherwise + the site needs `value ?? model.field`, or the clear is the intended new behaviour. + +Compile breaks in lib usually need a local to carry flow analysis (`translatedName(language)` called twice is not +promoted). Tests that pin the old "null keeps" behaviour flip to the new behaviour. + +For a model not yet on freezed, the report covers `copyWith` only. freezed also replaces `==`, `hashCode` and +`toString` (for example, every field counts in `==`, where `Equatable` compared only `props`); check those +separately. + +What a changed site means for apps goes in the changelog and the migration guide: list the model's fields under +the rows of the table that apply. + +## Known limits + +* It finds the hand-written `copyWith` by the shape `Type copyWith({`; a positional or oddly formatted one has to + be removed by hand first. +* A call through `dynamic` or a tear-off isn't type-checked, so it isn't reported. +* A generated file that is untracked and rewritten by the build can't be restored; commit generated files first. + +## Results on the poll models (PR #3037) + +* `Poll`: 1 compile break in lib (`stream_message_preview_text.dart:96`), `withTranslationsOf` unchanged, + sentinel sites unchanged, 1 test to rewrite (`poll_test.dart:40`). Constructor-default trap on `id`, + `createdAt`, `updatedAt`. +* `PollOption`: no lib changes; 1 test no longer compiles (`poll_option_test.dart:43`). +* `PollVote`: no lib changes; 1 test to rewrite (`poll_vote_test.dart:23`). +* Applying the `Poll` switch for real: analyze clean in all packages, and the only failing non-golden test is the + one pinning v10 behaviour. diff --git a/.claude/skills/openapi-migration/scripts/copywith_audit.py b/.claude/skills/openapi-migration/scripts/copywith_audit.py new file mode 100755 index 0000000000..00d03d6077 --- /dev/null +++ b/.claude/skills/openapi-migration/scripts/copywith_audit.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +"""Audit what breaks when one model's hand-written copyWith is replaced by freezed's. + +Usage: python3 .claude/skills/openapi-migration/scripts/copywith_audit.py [--packages ...] + + is a class name (`Poll`) or `path/to/file.dart:Poll`. Run it inside the repo, after `melos bootstrap`. + +The probe edits only the model's file and the generated `*.freezed.dart` / `*.g.dart` files of the model's package, +and restores exactly those at the end, even on failure. It refuses to start while any of them has uncommitted +changes, so nothing of yours is ever overwritten. +""" + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + + +def sh(cmd, cwd=None, env=None, check=False): + return subprocess.run(cmd, cwd=cwd or ROOT, env=env, text=True, capture_output=True, check=check) + + +ROOT = Path(subprocess.run(['git', 'rev-parse', '--show-toplevel'], text=True, capture_output=True).stdout.strip() + or Path.cwd()) +GENERATED = ('.freezed.dart', '.g.dart') + + +# --- Files the probe may touch ----------------------------------------------------------------------------------- + + +def managed_status(model: Path, pkg: Path): + """git status of the files the probe may touch: the model file and the package's generated files.""" + rel_pkg = os.path.relpath(pkg, ROOT) + out = sh(['git', 'status', '--porcelain', '--untracked-files=all', '--', f'{rel_pkg}/lib']).stdout + status = {} + for line in out.splitlines(): + path = line[3:].split(' -> ')[-1] + if path == str(model) or path.endswith(GENERATED): + status[path] = line[:2] + return status + + +def restore(model: Path, pkg: Path, untracked_before): + """Puts every managed file back as git has it; removes generated files the probe created.""" + for path, code in managed_status(model, pkg).items(): + if code == '??': + if path not in untracked_before: + (ROOT / path).unlink(missing_ok=True) + else: + sh(['git', 'checkout', '--', path]) + + +# --- Toolchain --------------------------------------------------------------------------------------------------- + + +def pinned_env(): + env = dict(os.environ) + fvmrc = ROOT / '.fvmrc' + if fvmrc.exists(): + pin = json.loads(fvmrc.read_text()).get('flutter') + bin_dir = Path.home() / 'fvm' / 'versions' / str(pin) / 'bin' + if bin_dir.exists(): + env['PATH'] = f'{bin_dir}{os.pathsep}{env["PATH"]}' + return env + + +def package_of(path: Path) -> Path: + for parent in (ROOT / path).resolve().parents: + if (parent / 'pubspec.yaml').exists(): + return parent + sys.exit(f'No pubspec.yaml above {path}') + + +def default_packages(): + out = sh(['git', 'ls-files', '*pubspec.yaml']).stdout.split() + return [ROOT / Path(p).parent for p in out + if (ROOT / Path(p).parent / '.dart_tool' / 'package_config.json').exists()] + + +# --- Locating and rewriting the model ---------------------------------------------------------------------------- + + +def find_model(spec): + if ':' in spec: + path, name = spec.rsplit(':', 1) + return Path(os.path.relpath(Path(path).resolve(), ROOT)), name + hits = [Path(p) for p in sh(['git', 'grep', '-lE', rf'^(abstract |final )*class {spec}( |<|$)', + '--', ':(glob)packages/*/lib/**/*.dart']).stdout.split() + if not p.endswith(GENERATED)] + if len(hits) != 1: + sys.exit(f'Found {len(hits)} files declaring `class {spec}`: {hits}. Pass `path/to/file.dart:{spec}`.') + return hits[0], spec + + +def match_close(src, i, open_ch, close_ch): + """Index just past the bracket that closes the one at src[i].""" + depth, quote = 0, None + while i < len(src): + c = src[i] + if quote: + if c == '\\': + i += 2 + continue + if c == quote: + quote = None + elif c in '\'"': + quote = c + elif c == open_ch: + depth += 1 + elif c == close_ch: + depth -= 1 + if depth == 0: + return i + 1 + i += 1 + raise ValueError('unbalanced brackets') + + +def class_span(src, name): + """(header_start, decl_start, body_start, body_end); the header is the doc comment and annotations.""" + m = re.compile(rf'^(?:abstract |final |sealed |base )*class {re.escape(name)}\b[^{{]*\{{', re.M).search(src) + if not m: + sys.exit(f'`class {name}` not found.') + body_start = m.end() - 1 + body_end = match_close(src, body_start, '{', '}') + lines = src[:m.start()].split('\n')[:-1] + while lines and re.match(r'\s*(///|//|@|\)|\w+:)', lines[-1]): + lines.pop() + header_start = len('\n'.join(lines)) + (1 if lines else 0) + return header_start, m.start(), body_start, body_end + + +def parse_params(params_src): + """{name: (type, has_sentinel)} for a `{...}` parameter list's text.""" + out = {} + for p in re.finditer(r'([\w<>?, ]+?)\s+(\w+)\s*(=\s*_nullConst)?\s*,', params_src + ','): + out[p.group(2)] = (p.group(1).replace('required ', '').strip(), bool(p.group(3))) + return out + + +def remove_copy_with(body): + """Strips the hand-written copyWith from a class body; returns (body, params).""" + m = re.search(r'^([ \t]+)[\w<>?, ]+\s+copyWith\(\{', body, re.M) + if not m: + return body, {} + lines = body[:m.start()].split('\n')[:-1] + while lines and re.match(r'\s*(///|@)', lines[-1]): + lines.pop() + start = len('\n'.join(lines)) + (1 if lines else 0) + + paren = body.index('(', m.start()) + params_end = match_close(body, paren, '(', ')') + params = parse_params(body[paren + 2:params_end - 1]) + rest = body[params_end:] + end = params_end + len(rest) - len(rest.lstrip()) + if body.startswith('=>', end): + while body[end] != ';': + c = body[end] + end = match_close(body, end, c, {'(': ')', '[': ']', '{': '}'}[c]) if c in '([{' else end + 1 + end += 1 + else: + end = match_close(body, end, '{', '}') + if body[end:end + 1] == '\n': + end += 1 + return body[:start] + body[end:], params + + +def put_on_freezed(src, name, header_start, decl_start): + """Makes the class generate freezed's copyWith; returns (src, was_already_freezed).""" + header, decl = src[header_start:decl_start], src[decl_start:] + if re.search(r'@Freezed\([^)]*copyWith:\s*false', header): + def fix(m): + args = re.sub(r'copyWith:\s*false\s*,?\s*', '', m.group(1)).strip().rstrip(',') + return '@freezed' if not args else f'@Freezed({args})' + + header = re.sub(r'@Freezed\(([^)]*copyWith:\s*false[^)]*)\)', fix, header) + return src[:header_start] + header + decl, True + if re.search(r'@(freezed|Freezed)\b', header): + return src, True + + # Not on freezed yet: annotate the class, mix in the generated code, and add the part and import. + header += '@freezed\n' + head_end = decl.index('{') + head = decl[:head_end] + if re.search(r'\bwith\b', head): + head = re.sub(r'\bwith\s+', f'with _${name}, ', head, count=1) + elif ' implements ' in head: + head = head.replace(' implements ', f' with _${name} implements ', 1) + else: + head = head.rstrip() + f' with _${name} ' + src = src[:header_start] + header + head + decl[head_end:] + + part = f"part '{MODEL.stem}.freezed.dart';" + if part not in src: + parts = list(re.finditer(r"^part '.*';\n", src, re.M)) + imports = list(re.finditer(r"^import '.*';\n", src, re.M)) + anchor = parts[-1].end() if parts else imports[-1].end() if imports else 0 + src = src[:anchor] + ('' if parts else '\n') + part + '\n' + src[anchor:] + if 'package:freezed_annotation/freezed_annotation.dart' not in src: + first_import = re.search(r"^import ", src, re.M) + at = first_import.start() if first_import else 0 + src = src[:at] + "import 'package:freezed_annotation/freezed_annotation.dart' show freezed;\n" + src[at:] + return src, False + + +# --- Generated code ---------------------------------------------------------------------------------------------- + + +def interface(freezed_src, name): + """The match of the generated `$Res call({...});` interface of the model's copyWith.""" + head = re.search(rf'class [_$]*{re.escape(name)}CopyWith<\$Res>', freezed_src) + if not head: + return None + return re.compile(r'\$Res call\(\{([^{}]*?)\}\);').search(freezed_src, head.end()) + + +def interface_params(body): + return {m.group(2): m.group(1) for m in re.finditer(r'^\s*(.+?)\s+(\w+),\s*$', body, re.M)} + + +def field_types(body): + return {m.group(2): m.group(1) for m in re.finditer(r'^\s+final\s+([\w<>?, ]+?)\s+(\w+);', body, re.M)} + + +def bare(type_): + return type_.strip().rstrip('?') + + +# --- Analysis ---------------------------------------------------------------------------------------------------- + + +def analyze(packages, env): + errors = {} + for pkg in packages: + out = sh(['dart', 'analyze', '--format=machine', '.'], cwd=pkg, env=env) + for line in (out.stdout + out.stderr).splitlines(): + parts = line.split('|') + if len(parts) >= 8 and parts[0] == 'ERROR': + errors[(os.path.relpath(parts[3], ROOT), int(parts[4]), int(parts[5]))] = (parts[2], parts[7]) + return errors + + +def arg_name(path, line, col, code, msg): + if code == 'UNDEFINED_NAMED_PARAMETER': + m = re.search(r"named parameter '(\w+)'", msg) + if m: + return m.group(1) + text = (ROOT / path).read_text().split('\n')[line - 1] + m = re.search(r'(\w+)\s*:\s*$', text[:col - 1]) + return m.group(1) if m else '?' + + +def source_line(path, line): + return (ROOT / path).read_text().split('\n')[line - 1].strip() + + +# --- Main -------------------------------------------------------------------------------------------------------- + + +def main(): + global MODEL + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('model', help='class name, or path/to/file.dart:ClassName') + ap.add_argument('--packages', nargs='*', help='package dirs to analyze (default: every bootstrapped package)') + args = ap.parse_args() + os.chdir(ROOT) + + MODEL, name = find_model(args.model) + pkg = package_of(MODEL) + before = managed_status(MODEL, pkg) + dirty = [p for p, code in before.items() if code != '??'] + if dirty: + sys.exit('These files have uncommitted changes and the probe would overwrite them; commit or stash first:\n' + + '\n'.join(f' {p}' for p in dirty)) + untracked_before = set(before) + + env = pinned_env() + packages = [ROOT / p for p in args.packages] if args.packages else default_packages() + freezed = MODEL.with_name(MODEL.stem + '.freezed.dart') + try: + src = MODEL.read_text() + header_start, decl_start, body_start, body_end = class_span(src, name) + body, v10_params = remove_copy_with(src[body_start:body_end]) + if not v10_params: + sys.exit(f'`{name}` has no hand-written copyWith; freezed\'s is new API, not a behaviour change.') + src = src[:body_start] + body + src[body_end:] + src, was_freezed = put_on_freezed(src, name, header_start, decl_start) + MODEL.write_text(src) + + # build_runner caches by input digest, and an earlier run's outputs were restored behind its back, so a + # rerun would keep them. Deleting the output forces the rebuild. + freezed.unlink(missing_ok=True) + rel = os.path.relpath((ROOT / MODEL).resolve(), pkg) + r = sh(['dart', 'run', 'build_runner', 'build', f'--build-filter={rel[:-5]}.*.dart'], cwd=pkg, env=env) + generated = freezed.read_text() if freezed.exists() else '' + call = interface(generated, name) + if r.returncode or not call: + sys.exit(f'build_runner did not generate a copyWith for `{name}`:\n{r.stdout[-3000:]}{r.stderr[-3000:]}') + # A filtered build deletes the package's other outputs; put them back before analyzing. + restore_deleted(pkg) + + generated_params = interface_params(call.group(1)) + fields = field_types(body) + missing = [p for p in v10_params if p not in generated_params] + retyped = [(p, t, fields[p]) for p, t in generated_params.items() + if p in fields and bare(t) != bare(fields[p])] + ctor_traps = [p for p, t in generated_params.items() + if t.endswith('?') and p in fields and not fields[p].endswith('?')] + + print(f'Analyzing `{name}` (pass 1: compile breaks)…', file=sys.stderr) + compile_errors = analyze(packages, env) + + stripped = re.sub(r'^(\s*)(.+?)\?(\s+\w+,)\s*$', r'\1\2\3', call.group(1), flags=re.M) + freezed.write_text(generated[:call.start(1)] + stripped + generated[call.end(1):]) + print(f'Analyzing `{name}` (pass 2: nullable arguments)…', file=sys.stderr) + nullable_errors = {k: v for k, v in analyze(packages, env).items() if k not in compile_errors} + + report(name, was_freezed, compile_errors, nullable_errors, v10_params, missing, retyped, ctor_traps) + finally: + restore(MODEL, pkg, untracked_before) + restore_deleted(pkg) + print('Restored the model and its package\'s generated files.', file=sys.stderr) + + +def restore_deleted(pkg): + rel_pkg = os.path.relpath(pkg, ROOT) + deleted = [p for p in sh(['git', 'ls-files', '--deleted', '--', f'{rel_pkg}/lib']).stdout.split() + if p.endswith(GENERATED)] + if deleted: + sh(['git', 'checkout', '--', *deleted]) + + +# --- Report ------------------------------------------------------------------------------------------------------ + + +def report(name, was_freezed, compile_errors, nullable_errors, v10_params, missing, retyped, ctor_traps): + def row(key, value, tag=''): + (file, line, col), (code, msg) = key, value + arg = arg_name(file, line, col, code, msg) + kind = 'test' if '/test/' in f'/{file}' else 'lib' + return arg, f'- [{kind}] `{file}:{line}` `{arg}:`{tag}\n `{source_line(file, line)}`\n {msg}' + + missing_rows, compile_rows = [], [] + for key, value in sorted(compile_errors.items()): + arg, text = row(key, value) + (missing_rows if value[0] == 'UNDEFINED_NAMED_PARAMETER' and arg in missing else compile_rows).append(text) + + nullable_rows = [] + for key, value in sorted(nullable_errors.items()): + arg = arg_name(*key, *value) + sentinel = v10_params.get(arg, (None, False))[1] + nullable_rows.append(row(key, value, ' — v10 sentinel, same result' if sentinel + else ' — **v10 kept on null; check**')[1]) + + def section(title, items, note=None): + print(f'\n## {title}\n') + if note and items: + print(note + '\n') + print('\n'.join(items) if items else 'None.') + + print(f'# freezed copyWith audit: `{name}` ({MODEL})') + if not was_freezed: + print(f'\n`{name}` is not on freezed yet; the probe added `@freezed` for this run only. It audits `copyWith`; ' + 'freezed would also replace `==`, `hashCode` and `toString`, which this does not check.') + if missing or retyped: + print('\n**Verdict: freezed\'s `copyWith` cannot replace this one as it stands.** freezed builds `copyWith` ' + 'from the constructor and keeps a parameter only when a field has the same name and type. Keep ' + '`@Freezed(copyWith: false)` with the hand-written method, or reshape the constructor first.') + section('Parameters freezed does not generate', [f'- `{p}`' for p in missing] + missing_rows, + 'A constructor parameter backed by a getter, a private field or a differently named field gets no ' + '`copyWith` parameter. Every call below stops compiling.') + section('Parameters whose type changes', [f'- `{p}`: `{t}` in `copyWith`, `{f}` on the field' for p, t, f in retyped], + 'freezed types the parameter from the constructor, not the field.') + section('Compile breaks (non-nullable parameter given a nullable value, or a retyped parameter)', compile_rows) + section('Nullable argument sites (null now clears instead of keeping)', nullable_rows) + section('Constructor-default trap (passing null regenerates the default)', [f'- `{p}`' for p in ctor_traps]) + sentinels = [f'`{p}`' for p, (_, s) in v10_params.items() if s] + section('v10 parameters with a `_nullConst` sentinel', [', '.join(sentinels)] if sentinels else []) + + +MODEL = Path() + +if __name__ == '__main__': + main() diff --git a/CLAUDE.md b/CLAUDE.md index 89a1d5d91f..9f82fac6d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,7 +120,8 @@ Optional local persistence using Drift (SQLite). Implements `ChatPersistenceClie ## Code Style -- Line length: **120 characters** (configured in `analysis_options.yaml`) +- Line length: **120 characters** for code (configured in `analysis_options.yaml`); wrap + docs and comments at **80** - Imports: inside a package's own `lib/`, use relative imports (`prefer_relative_imports`); use `package:` imports for anything from another package - All public APIs **must** have doc comments (`public_member_api_docs`) — see diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 628dc64f2e..5a9ceb0f9c 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -60,8 +60,8 @@ document; the section link is provided. **Code** -- Line width: **120 characters** (configured in `analysis_options.yaml`). Comments and - docs follow the same limit. +- Line width: **120 characters** for code (configured in `analysis_options.yaml`). Wrap + comments and docs at **80**. - Single quotes, relative imports within a package's own `lib/`, trailing commas preserved, `const` wherever possible, `final` for locals that aren't reassigned. These are enforced by the linter — do not disable them. @@ -1377,10 +1377,12 @@ melos run lint:all # analyze + format, run this before opening a PR checked with the same settings. Do not invoke `dart format` on a single file with ad-hoc flags — the workspace-level config (line length, trailing commas) applies uniformly. -Line length is **120 characters** for both code and comments, configured in -`analysis_options.yaml`. Trailing commas are preserved rather than automatically added, -so include a trailing comma anywhere you want the formatter to break the argument list -onto multiple lines. +Line length is **120 characters** for code, configured in `analysis_options.yaml`. +Comments and docs wrap at **80**: prose that wide is easier to read in IDE hovers and +side-by-side diffs, and the formatter never reflows it, so wrap it by hand. + +Trailing commas are preserved rather than automatically added, so include a trailing +comma anywhere you want the formatter to break the argument list onto multiple lines. ### Constructors come first diff --git a/migrations/v11-migration.md b/migrations/v11-migration.md index 022dbf1998..f691a00331 100644 --- a/migrations/v11-migration.md +++ b/migrations/v11-migration.md @@ -30,6 +30,7 @@ onto Stream's OpenAPI-generated API client. - [Moderation](#moderation) - [App Settings](#app-settings) - [Guest Users](#guest-users) + - [Polls](#polls) - [File Upload](#file-upload) - [User Groups](#user-groups) - [Link Previews](#link-previews) @@ -77,6 +78,7 @@ from the spec, so don't subclass them or depend on their private constructors. | [**Moderation**](#moderation) | Muting, banning and flagging return a `Result` and call the moderation v2 API; `banUser`'s options map becomes named parameters; `unflagMessage`, `unflagUser` and `removeShadowBan` are removed | | [**App Settings**](#app-settings) | `getAppSettings` returns a `Result` instead of throwing; `GetAppSettingsResponse` is renamed `AppSettingsResponse` | | [**Guest Users**](#guest-users) | `connectGuestUser` is unchanged; `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed | +| [**Polls**](#polls) | Poll calls return a `Result`; eight responses become `PollResponse`, `PollOptionResponse` and `PollVoteResponse`; queries take `limit`/`next`/`prev`; `VotingVisibility` is an extension type; `extraData` is `custom` | | [**File Upload**](#file-upload) | Uploads return a `Result` and deletes a `Result` instead of throwing; `AttachmentFileUploaderProvider` receives a `Dio` | | _(filled in per feature as PRs land)_ | | @@ -214,6 +216,26 @@ search-and-replace you can apply directly. `Kind` is one of `renamed`, `removed` | `StreamChatApi.general.getAppSettings()` | `StreamChatClient.getAppSettings()` | `removed` | The call moved to the generated client | | `StreamChatApi.guest` (`GuestApi.getGuestUser`) | `StreamChatClient.connectGuestUser` | `removed` | The call moved to the generated client; `connectGuestUser` keeps its signature and still throws | | `ConnectGuestUserResponse` | — | `removed` | Only `StreamChatApi.guest` returned it; `connectGuestUser` still returns the connected `OwnUser` | +| `StreamChatClient` poll methods → `Future` and the like | `Future>` and the like | `retyped` | `createPoll`, `getPoll`, `updatePoll`, `partialUpdatePoll`, `closePoll`, `createPollOption`, `getPollOption`, `updatePollOption`, `castPollVote`, `addPollAnswer`, `removePollVote`, `queryPolls` and `queryPollVotes` return a `Result` instead of throwing | +| `StreamChatClient.deletePoll` / `.deletePollOption` → `Future` | `Future>` | `retyped` | No value on success | +| `Channel.sendPoll` / `.updatePoll` / `.deletePoll` / `.closePoll` / `.createPollOption` / `.castPollVote` / `.addPollAnswer` / `.removePollVote` / `.queryPollVotes` | the same names, returning a `Result` | `retyped` | `sendPoll` also returns a failure when the message cannot be sent, rather than throwing | +| `CreatePollResponse` / `GetPollResponse` / `UpdatePollResponse` | `PollResponse` | `renamed` | Same fields: `duration` and `poll` | +| `CreatePollOptionResponse` / `GetPollOptionResponse` / `UpdatePollOptionResponse` | `PollOptionResponse` | `renamed` | Same fields: `duration` and `pollOption` | +| `CastPollVoteResponse` / `RemovePollVoteResponse` | `PollVoteResponse` | `renamed` | `vote` is nullable | +| `queryPolls(pagination: PaginationParams(limit: l, next: n))` / `queryPollVotes(…)` | `queryPolls(limit: l, next: n)` / `queryPollVotes(…)` | `retyped` | `limit` is still 10 when omitted. Pass a response's `prev` as `prev` to page backwards. The other `PaginationParams` fields never had an effect on these queries | +| `Channel.castPollVote` / `.removePollVote` throwing `ArgumentError` | a `Failure` carrying a `StreamClientException` | `retyped` | For an option or vote without an id | +| `Poll.copyWith` / `PollOption.copyWith` / `PollVote.copyWith` keeping a field passed as `null` | freezed's `copyWith` | `retyped` | `null` clears a nullable field and re-applies the constructor default of `Poll.id`, `createdAt` and `updatedAt`; `Poll.maxVotesAllowed` and `PollOption.id` already cleared | +| `Poll.extraData` / `PollOption.extraData` | `Poll.custom` / `PollOption.custom` | `renamed` | The wire's name for it; the first models to take it | +| `Poll.custom` / `PollOption.custom` entry named like one of the model's own fields | the same entry, as custom data | `retyped` | Sent and returned as custom data; v10's request body let it overwrite the field | +| `StreamPollController.addOption(extraData:)` / `.updateOption(extraData:)` (`stream_chat_flutter_core`) | `custom:` | `renamed` | Follows `PollOption.custom` | +| `Poll.topLevelFields` / `PollOption.topLevelFields` | — | `removed` | They listed the JSON keys of models that no longer have JSON | +| `VotingVisibility` (enum) | `VotingVisibility` (extension type over `String`) | `retyped` | `VotingVisibility.public` and `.anonymous` are unchanged; a `switch` needs a default arm, and `.name` / `.values` are gone — it is a `String`, so use it directly | +| `Poll.fromJson` / `.toJson`, `PollOption.fromJson` / `.toJson`, `PollVote.fromJson` / `.toJson` | — | `removed` | The models are plain classes; construct them directly. `Message.poll` and the poll events still decode from the same keys | +| — | `PollOption.fromData` / `.toData` | `added` | Read and write only the format the offline database stores; not a codec for API payloads | +| `CreatePollResponse()..poll = …` and the other response setters | `PollResponse(duration: …, poll: …)` | `retyped` | The responses are plain classes with a const constructor and final fields | +| poll responses' `duration` (`String?`) | `String` | `retyped` | Always present; drop any `!` or `?? ''` | +| `Poll` / `PollOption` / `PollVote extends Equatable`, `props` | value `==` | `removed` | `Poll` and `PollOption` now compare `custom` too | +| `StreamChatApi.polls` (`PollsApi`) | `StreamChatClient`'s poll methods | `removed` | The endpoints moved to the generated client | | `sendImage` / `sendFile` / `uploadImage` / `uploadFile` → `Future` and siblings | `Future>` | `retyped` | On `StreamChatClient`, `Channel` and `AttachmentFileUploader`. Returns a `Result` instead of throwing | | `deleteImage` / `deleteFile` / `removeImage` / `removeFile` → `Future` | `Future>` | `retyped` | On `StreamChatClient`, `Channel` and `AttachmentFileUploader`. Returns a `Result` instead of throwing | | `SendAttachmentResponse`, `SendFileResponse`, `SendImageResponse`, `UploadImageResponse`, `UploadFileResponse` | `UploadedFile` (`stream_core`) | `removed` | `.file` becomes `.fileUrl`; `.thumbUrl` is unchanged; `duration` is gone | @@ -736,6 +758,108 @@ queried, and the unread counts when connecting with `connectWebSocket: false`. N guest. v11 sends its id, name, image, language, `invisible`, an `OwnUser`'s privacy settings, and your own `extraData`. If you read one of those values back, store it as a custom field of your own instead. +### Polls + +**Every poll call returns a `Result` instead of throwing,** on `StreamChatClient` and on `Channel`. +`deletePoll` and `deletePollOption` carry no value on success. Calling a `Channel` method on a channel that is not +initialized still throws a `StateError`, as in v10. + +```dart +// v10 +try { + final response = await client.getPoll(pollId); + showPoll(response.poll); +} on StreamChatException catch (e) { + report(e); +} + +// v11 +final result = await client.getPoll(pollId); +result.fold( + onSuccess: (response) => showPoll(response.poll), + onFailure: (error, _) => report(error), +); +``` + +**`Channel.sendPoll` reports both of its steps through the `Result`.** It creates the poll and then sends it in a +message; a failure in either comes back as a `Failure`, where v10 threw. If the message fails, the poll stays created. + +**Eight responses become three.** Creating, fetching and updating a poll all answer a `PollResponse`; the same calls +on an option answer a `PollOptionResponse`; casting and removing a vote answer a `PollVoteResponse`. The fields are +unchanged, except that `PollVoteResponse.vote` is nullable. + +```dart +// v10 +final CreatePollResponse created = await client.createPoll(poll); + +// v11 +final Result created = await client.createPoll(poll); +``` + +**`queryPolls` and `queryPollVotes` take `limit`, `next` and `prev`** instead of a `PaginationParams`. `limit` is still +10 when omitted. The offset and id-based fields of `PaginationParams` never had an effect on these queries. The +responses now carry a `prev` cursor next to `next`; pass it as `prev` to fetch the page before. + +```dart +// v10 +await channel.queryPollVotes(pollId, pagination: PaginationParams(limit: 10, next: cursor)); + +// v11 +await channel.queryPollVotes(pollId, limit: 10, next: cursor); +``` + +**A missing id is a failure, not an `ArgumentError`.** `Channel.castPollVote` with an option that has no id, and +`removePollVote` with a vote that has none, return a `Failure` carrying a `StreamClientException`. + +**`VotingVisibility` is an extension type over its wire string.** `VotingVisibility.public` and +`VotingVisibility.anonymous` read the same, and a visibility the SDK does not name is kept rather than rejected. +A `switch` over it needs a default arm, and `.name` / `.values` are gone — it is a `String`, so use it directly. + +**`Poll`, `PollOption`, `PollVote` and the responses no longer decode JSON.** Build them with their constructors — +`PollResponse(duration: '0ms', poll: poll)` where v10 wrote `CreatePollResponse()..poll = poll`. `Message.poll` and +the poll events still decode from the same keys. `PollOption.fromData` and `toData` read and write the format the +offline database stores; they are not a way to decode API responses. `Poll.topLevelFields` and +`PollOption.topLevelFields`, which listed their JSON keys, are gone with the JSON. + +**`Poll`, `PollOption` and `PollVote` no longer extend `Equatable`.** They still compare by value and `props` is gone. +`Poll` and `PollOption` now include their custom data in `==`: two polls that differ only in custom data are no +longer equal, so neither are the messages that carry them, and a widget comparing them rebuilds when only custom +data changes. + +**`extraData` is renamed `custom`, and holds the poll's custom data verbatim.** `Poll.custom` and +`PollOption.custom` hold what the API returns as custom data, and the requests send it back as custom data; the +same rename applies to `StreamPollController.addOption` and `updateOption`. In v10 an entry named like one of the +poll's own fields — `name`, `is_closed`, an option's `text` — overwrote that field in the request body; it is now +stored as custom data under that name. + +```dart +// v10 +final topic = poll.extraData['topic']; +Poll(name: 'Lunch?', options: options, extraData: {'topic': 'food'}); + +// v11 +final topic = poll.custom['topic']; +Poll(name: 'Lunch?', options: options, custom: {'topic': 'food'}); +``` + +**`copyWith` on `Poll`, `PollOption` and `PollVote` is freezed's.** Passing `null` for a nullable field clears it, +where v10 kept the value: `description`, `nameI18n`, `descriptionI18n`, `createdById` and `createdBy` on `Poll`, +`textI18n` on `PollOption`, and every nullable field of `PollVote`. `Poll.maxVotesAllowed` and `PollOption.id` +already cleared. Passing `null` for `Poll.id`, `createdAt` or `updatedAt`, or a vote's `createdAt` or +`updatedAt`, applies the constructor default — a new id or the current time — instead of keeping the value. Omit +the argument to keep a value. + +```dart +// v10: description stays 'Pick one' +poll.copyWith(description: null); + +// v11: description is null; omit it to keep it +poll.copyWith(description: null); +poll.copyWith(name: 'Dinner?'); +``` + +**`StreamChatApi.polls` is removed.** Call the poll methods on `StreamChatClient` or `Channel` instead. + ### File Upload **Uploads and deletes return a `Result` instead of throwing.** `sendImage`, `sendFile`, `uploadImage` and diff --git a/openapi-migration/02-devices.md b/openapi-migration/02-devices.md index 71e2e9abeb..5bc5624a3b 100644 --- a/openapi-migration/02-devices.md +++ b/openapi-migration/02-devices.md @@ -89,6 +89,8 @@ Revisited after landing: the group first adopted the generated `DeviceResponse`, hand-written, with the reason. - [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). - [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [x] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/03-user-groups.md b/openapi-migration/03-user-groups.md index 36c1e01060..e3f0cf9329 100644 --- a/openapi-migration/03-user-groups.md +++ b/openapi-migration/03-user-groups.md @@ -73,6 +73,8 @@ Follows the [domain-model rules](README.md#domain-models). hand-written, with the reason. - [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). - [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [x] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/04-roles-guest-and-app.md b/openapi-migration/04-roles-guest-and-app.md index e8df714c1d..db99df5cf1 100644 --- a/openapi-migration/04-roles-guest-and-app.md +++ b/openapi-migration/04-roles-guest-and-app.md @@ -113,6 +113,8 @@ hand-written, with the reason. - [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). - [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [x] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/05-polls.md b/openapi-migration/05-polls.md index 39991be852..80fb91249f 100644 --- a/openapi-migration/05-polls.md +++ b/openapi-migration/05-polls.md @@ -2,7 +2,7 @@ **Goal:** First group with real domain models and persistence behind it. -**Size:** 13 hand-written method(s) across 1 file(s) → 13 generated operation(s). +**Size:** 0 hand-written method(s) across 0 file(s) → 13 generated operation(s). > This whole file is generated by `openapi-migration/tool/generate_plan.py`, prose included. > Edit its `GROUPS` entry and re-run the script — edits made here are lost on the next run. @@ -13,19 +13,6 @@ | File | Method | Returns | | --- | --- | --- | -| `polls_api.dart` | `createPoll` | `CreatePollResponse` | -| `polls_api.dart` | `getPoll` | `GetPollResponse` | -| `polls_api.dart` | `updatePoll` | `UpdatePollResponse` | -| `polls_api.dart` | `partialUpdatePoll` | `UpdatePollResponse` | -| `polls_api.dart` | `deletePoll` | `EmptyResponse` | -| `polls_api.dart` | `createPollOption` | `CreatePollOptionResponse` | -| `polls_api.dart` | `getPollOption` | `GetPollOptionResponse` | -| `polls_api.dart` | `updatePollOption` | `UpdatePollOptionResponse` | -| `polls_api.dart` | `deletePollOption` | `EmptyResponse` | -| `polls_api.dart` | `castPollVote` | `CastPollVoteResponse` | -| `polls_api.dart` | `removePollVote` | `RemovePollVoteResponse` | -| `polls_api.dart` | `queryPolls` | `QueryPollsResponse` | -| `polls_api.dart` | `queryPollVotes` | `QueryPollVotesResponse` | ### Generated operations that cover it @@ -45,11 +32,72 @@ | `PUT` | `/api/v2/polls/{poll_id}/options` | `updatePollOption` | `PollOptionResponse` | | `PATCH` | `/api/v2/polls/{poll_id}` | `updatePollPartial` | `PollResponse` | -## Decisions to make +## Decisions taken -- `Poll`, `PollOption` and `PollVote` are public and persisted; adopting generated shapes means touching `stream_chat_persistence` in the same PR. -- `VotingVisibility` is ours; the generated equivalent is an inline per-operation enum. -- One generated `PollResponse` answers `createPoll`, `getPoll`, `updatePoll` and `updatePollPartial`, where v10 has `CreatePollResponse`, `GetPollResponse` and `UpdatePollResponse`. The v10 envelopes stay; decide with the user how the mapper names its conversions, since one `toModel()` cannot return three types. `PollOptionResponse` (create, get, update an option) and `PollVoteResponse` (cast, remove a vote) have the same shape. +- **`Poll`, `PollOption` and `PollVote` keep their v10 names, fields and defaults,** as `@freezed` + classes with no JSON, mapped from `PollResponseData`, `PollOptionResponseData` and + `PollVoteResponseData` in `lib/src/repository/mapper/polls_mapper.dart`. The uuid default `id` and the + `DateTime.now()` timestamps stay; `PollVote.isAnswer` becomes a getter, and `latestVotes`, `ownVotes` and + `ownAnswers` stay `late final` caches. +- **Equality now includes `extraData`.** v10's `Equatable` props left it out of `Poll` and `PollOption`; + freezed compares every field, and so does `Message`, whose props include its poll. Accepted as a + behavioural break and flagged for review. +- **The eight v10 envelopes collapse to the three the server returns:** `PollResponse` (create, get, + update, partial update), `PollOptionResponse` (create, get, update an option) and `PollVoteResponse` + (cast, remove a vote), each with one `toModel()`. `QueryPollsResponse` and `QueryPollVotesResponse` + keep their names. A rename break beyond the [sanctioned ones](README.md#domain-models), approved for + this group and flagged for review. `PollVoteResponse.vote` is nullable, as the spec marks it. +- **The fields only the generated types carry stay out:** the `*_i18n` maps, `is_answer` (derived from + `answerText`) and `PollVoteResponse.poll`. Adding them is additive. The i18n keys are known fields + (`topLevelFields`), so neither the mapper nor the v1 decoder puts them in `extraData`, and a poll + compares equal whichever path delivered it. +- **Write methods keep taking `Poll` and `PollOption`.** The mapper sends only the writable settings; + the vote summary, `createdBy` and timestamps are not sent. Dedicated request types were considered + and rejected: the draft a caller builds must stay a `Poll`, because a message carries one. Flagged + for review. +- **Option ids are not sent on create.** `PollOptionInput` has none; the server assigns them and + rejects a client id as reserved (confirmed live: v10 answered 400). **`updatePoll` and + `updatePollOption` send an option without an id with an empty one** and return the server's failure; + the SDK does not pre-check a rule the backend owns. +- **`queryPolls` and `queryPollVotes` take `limit`, `next` and `prev`** instead of `PaginationParams`. + The generated requests carry only those, and the backend never read the offset or id/date cursors on + these queries. `limit` defaults to 10, `PaginationParams`' default, so an omitted limit pages as in + v10. The responses expose `prev`, so the parameter is usable. Flagged for review. +- **`VotingVisibility` becomes an extension type over its wire string,** following `PushProvider`. An + unknown value is kept rather than rejected, in the mapper, the v1 decoder and persistence. +- **An answer's `optionId` stays `""`,** as the server sends it and v10 decoded it. +- **`custom` is `extraData`, verbatim, in both directions** (README rule 9). The response mappers copy the + generated `custom` map as is and the request mappers send `extraData` as `custom`; an entry named like + one of the model's own fields is custom data on v2, where v10's flattened body let it set the field. + The v1 converters own the key lists that split the flat JSON on read (`Poll.topLevelFields` and + `PollOption.topLevelFields` are removed), and leave such an entry out on write so it cannot overwrite + a key. +- **Every public poll method returns a `Result`,** on `StreamChatClient` and `Channel`. `deletePoll` and + `deletePollOption` answer `DurationResponse` and return `Result`. `closePoll` stays a + `partialUpdatePoll` that sets `is_closed`. +- **`Channel.sendPoll` returns `Result`.** A `createPoll` failure passes through + and no message is sent; a `StreamException` thrown by `sendMessage` (still v1) is caught into a + `Failure`. +- **A missing option or vote id is a `Failure(StreamClientException)`, not an `ArgumentError`,** in + `Channel.castPollVote` and `removePollVote`, matching the migrated never-throws contract. +- **v1 JSON keeps decoding through hand-written converters** (`PollV1JsonConverter`, + `PollVoteV1JsonConverter`) on `Message.poll`, `DraftMessage.poll`, `Event.poll` and `Event.pollVote`. + The WebSocket is v1-only and sends custom data flat, with no `custom` key, so the generated + `fromJson` would throw on it; the converters mirror v10's `fromJson` and tolerate missing keys. + `Event` writes these fields too, so its converters run both ways. +- **`PollOption` gets the `@DataSerializable` codec** for `polls.options`. The stored format nests + custom data under `extra_data` where v10 flattened it, so `schemaVersion` is bumped. +- **`Poll`, `PollOption` and `PollVote` use freezed's `copyWith`.** The audit found one lib site + (`stream_message_preview_text.dart`, a `String?` passed for `name`) and one test per model; the + merging of poll events in `channel_state_mutations.dart` and `Poll.withTranslationsOf` passes no + argument that is null while the field is set, so it is unchanged. `null` now clears a nullable field + and re-applies the `id` and timestamp defaults, recorded as a break. +- **`extraData` is renamed `custom` on `Poll` and `PollOption`,** the wire's name, with no deprecated + alias; `StreamPollController.addOption` and `updateOption` and the `polls.custom` column follow. + Every migrated model takes the name from here on (README rule 9); [group 15](15-custom-data.md) + renames whatever no group migrates. +- **Verified live** against the demo app with a before/after harness covering every endpoint, the + error cases and the WebSocket events, and in the sample app on web. ## Risks @@ -59,17 +107,19 @@ ## Definition of done -- [ ] Every method above either routes through `DefaultApi` or is listed here as deliberately left +- [x] Every method above either routes through `DefaultApi` or is listed here as deliberately left hand-written, with the reason. -- [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. -- [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. -- [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group +- [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. +- [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). +- [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. -- [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. -- [ ] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. -- [ ] Decisions recorded in this file, and the status box ticked in `README.md`. -- [ ] Public dartdoc follows [`STYLE_GUIDE.md` § Documentation](../STYLE_GUIDE.md#documentation) +- [x] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. +- [x] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. +- [x] Decisions recorded in this file, and the status box ticked in `README.md`. +- [x] Public dartdoc follows [`STYLE_GUIDE.md` § Documentation](../STYLE_GUIDE.md#documentation) and [`EFFECTIVE_DART_DOC.md`](../EFFECTIVE_DART_DOC.md), including on symbols this group retyped but whose docs it left alone. -- [ ] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each +- [x] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each name states its subject and behaviour. diff --git a/openapi-migration/06-reminders.md b/openapi-migration/06-reminders.md index f2ccb1b74e..91be7b453e 100644 --- a/openapi-migration/06-reminders.md +++ b/openapi-migration/06-reminders.md @@ -41,6 +41,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/07-threads-and-drafts.md b/openapi-migration/07-threads-and-drafts.md index b325ae5e0d..eb757b9e15 100644 --- a/openapi-migration/07-threads-and-drafts.md +++ b/openapi-migration/07-threads-and-drafts.md @@ -39,6 +39,7 @@ ## Risks +- `DraftMessage.poll` is never decoded from JSON: `poll` is not in `DraftMessage.topLevelFields`, so the key would land in `extraData`, and the draft payload carries `poll_id` only. Its `PollV1JsonConverter` exists so the generated code compiles. Decide here whether the field stays constructor-only or goes. - The four draft methods live in `message_api.dart`, not `threads_api.dart` — this group reaches into that file, and group 10 must leave those four alone. - `Draft` and `DraftMessage` are public, persisted, and read by `StreamDraftListController` in `stream_chat_flutter_core`. @@ -48,6 +49,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/08-moderation-and-blocklists.md b/openapi-migration/08-moderation-and-blocklists.md index 35e3686252..660da8e4a8 100644 --- a/openapi-migration/08-moderation-and-blocklists.md +++ b/openapi-migration/08-moderation-and-blocklists.md @@ -187,6 +187,8 @@ hand-written, with the reason. - [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). - [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [x] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/09-users.md b/openapi-migration/09-users.md index 3df0231010..1c6d0bf219 100644 --- a/openapi-migration/09-users.md +++ b/openapi-migration/09-users.md @@ -41,7 +41,7 @@ - `User` and `OwnUser` are public, persisted, and embedded in nearly every other response. This group restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the current class for every group before it, and stay. See [01-foundation](01-foundation.md). - `PrivacySettings` and the push-preference sub-shapes — decide per type. -- `UserResponse.toModel()` leaves `OwnUser.topLevelFields`, `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before` out of every user's `extraData` (`_shadowedCustomKeys`), where v1 kept the `OwnUser`-only keys in a plain user's `extraData`. Revisit once the mapper serves plain users. +- **`User.name` and `image` become fields** (README rule 9): `UserResponse` types them, and the constructor stops writing them into `extraData`; decide whether `name` keeps its fallback to `id`. `deleted_at`, `deactivated_at`, `revoke_tokens_issued_before` and `blocked_user_ids`, which `UserResponse` types and v1 kept in a plain user's `extraData`, become fields or are dropped, never `extraData`. `UserResponse.toModel()` then copies `custom` verbatim into the field, renamed `custom`, and `UserRequestMapper` stops reading `extraData['name']` and `['image']`. - `UserFilterField.shadowBanned` and `.bypassModeration` read `extraData`, which the generated `UserResponse` has no field to fill. - `updateUsers` reuses `User.toRequest()`. It is a full upsert, and v10's flattened body filed the user's client state (`online`, `banned`, `created_at` and similar) into the stored custom data on every call, as it did for guests in [04](04-roles-guest-and-app.md). Decide the same way here, for real users rather than fresh guests, and record it in the CHANGELOG. - The generated `UserRequest` sends explicit `null` for an unset `language` or `invisible`, where v10 left the key out. For a guest create that made no difference; for an upsert of an existing user, confirm live that a `null` does not reset a stored value differently from an omitted key. @@ -51,6 +51,7 @@ - Every other group depends on the `User` decision. - User data arrives over the WebSocket on nearly every event. - Landing `UserResponse` -> `User` unblocks the two fields [08](08-moderation-and-blocklists.md) had to drop from `MuteUsersResponse`: the `mutes` the call created and the `ownUser` it left behind. Adding them is additive for anyone reading the response, so revisit them here rather than leaving them dropped for good. +- Until then, a user mapped from `UserResponse` and the same user decoded from v1 JSON carry different `extraData`: the v1 path keeps `blocked_user_ids`, `deleted_at`, `deactivated_at` and `revoke_tokens_issued_before`, the mapper drops them. Equality includes `extraData`, so a `Poll` from a REST call (its `createdBy`, its votes' `user`) and the same poll from an event compare unequal. ## Definition of done @@ -58,6 +59,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. @@ -72,3 +75,6 @@ the table in `README.md`. - [ ] `user_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is gone. +- [ ] A user mapped from `UserResponse` and the same user decoded from v1 / WebSocket JSON compare equal. +- [ ] `user_mapper.dart` has no `_shadowedCustomKeys`: `User.custom` is the wire's `custom`, verbatim, and + no typed field is written into it. diff --git a/openapi-migration/10-messages.md b/openapi-migration/10-messages.md index 7a5d1ea5cc..14b775439f 100644 --- a/openapi-migration/10-messages.md +++ b/openapi-migration/10-messages.md @@ -65,6 +65,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/11-channels-and-members.md b/openapi-migration/11-channels-and-members.md index ba18c48dd2..b3a3766bad 100644 --- a/openapi-migration/11-channels-and-members.md +++ b/openapi-migration/11-channels-and-members.md @@ -73,14 +73,14 @@ ## Decisions to make - `ChannelState`, `ChannelModel` and `Member` are public, persisted, and rebuilt from WebSocket events. Keep ours and map. -- **Whether `ChannelModel` promotes its `extraData`-backed flags to real fields.** `disabled`, `hidden`, `muted`, `blocked` and `truncatedAt` all arrive as root fields on `ChannelResponse` but are pushed into `extraData` and read back through getters, which the constructor comment calls "for backwards compatibility". Promoting them is a break worth making in v11 if it is made at all, and it belongs with this group's model shape rather than with whichever phase happens to add the next flag. Raised on [#2958](https://github.com/GetStream/stream-chat-flutter/pull/2958). +- **`ChannelModel` promotes its `extraData`-backed flags to fields** (README rule 9). `disabled`, `hidden`, `muted`, `blocked` and `truncatedAt`, plus the keys the channel mapper parks today — `truncatedBy`, `muteExpiresAt`, `hideMessagesBefore`, `autoTranslationEnabled` and `autoTranslationLanguage` — become fields with persistence columns, and the constructor stops writing them into the custom data, which is renamed `custom`. `name` and `image` stay in it: `ChannelResponse` has no such fields. Raised on [#2958](https://github.com/GetStream/stream-chat-flutter/pull/2958). - `sync` returns `SyncResponse` — one of the two models that needed the WSEvent generator patch. Verify it decodes before relying on it. ## Risks - `sync` and `queryMembers` live in `general_api.dart`, not `channel_api.dart` — this group reaches into that file. - `queryChannels` drives the channel list controllers and the offline cache; a shape change here is felt everywhere. -- Channel `custom`/`extraData` promotion, same class of problem as messages. +- A consumer reading `channel.extraData['hidden']` and friends loses the key once the flags are fields; none in this repo. - Landing `ChannelResponse` -> `ChannelModel` is the last thing blocking [08](08-moderation-and-blocklists.md)'s `muteChannel`, which drops `channelMute`, `channelMutes` and `ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a `UserResponse?` where ours needs a non-nullable `ChannelModel` and `User`. It needs 09 as well. Decide the null case there too — ours are non-nullable, the generated ones are not, the same question [14](14-banned-users.md) records for `BanResponse.user`. ## Definition of done @@ -89,6 +89,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. @@ -99,3 +101,5 @@ retyped but whose docs it left alone. - [ ] Tests follow [`TESTING.md`](../TESTING.md): no `group` organizing a file by method, each name states its subject and behaviour. +- [ ] `channel_mapper.dart` has no `_shadowedCustomKeys`: `ChannelModel.custom` is the wire's `custom`, + verbatim, and no flag is written into it. diff --git a/openapi-migration/12-uploads-cdn.md b/openapi-migration/12-uploads-cdn.md index 3b5c2b0fa9..d92b88f161 100644 --- a/openapi-migration/12-uploads-cdn.md +++ b/openapi-migration/12-uploads-cdn.md @@ -74,6 +74,8 @@ hand-written, with the reason. - [x] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [x] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [x] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). - [x] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [x] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/13-push-preferences.md b/openapi-migration/13-push-preferences.md index 55b14189c8..a3eebd8aa8 100644 --- a/openapi-migration/13-push-preferences.md +++ b/openapi-migration/13-push-preferences.md @@ -38,6 +38,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/14-banned-users.md b/openapi-migration/14-banned-users.md index e6f0ea918f..a5b5db0516 100644 --- a/openapi-migration/14-banned-users.md +++ b/openapi-migration/14-banned-users.md @@ -45,6 +45,8 @@ hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. +- [ ] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. diff --git a/openapi-migration/15-custom-data.md b/openapi-migration/15-custom-data.md new file mode 100644 index 0000000000..b04ed5afd8 --- /dev/null +++ b/openapi-migration/15-custom-data.md @@ -0,0 +1,43 @@ +# 15 — Custom Data + +**Goal:** Every public model's custom data is named `custom` and is exactly the wire's `custom`. + +**Size:** 0 hand-written method(s) across 0 file(s) → 0 generated operation(s). + +> This whole file is generated by `openapi-migration/tool/generate_plan.py`, prose included. +> Edit its `GROUPS` entry and re-run the script — edits made here are lost on the next run. + +## Scope + +### Hand-written today + +| File | Method | Returns | +| --- | --- | --- | + +### Generated operations that cover it + +| Verb | Path | Operation | Response | +| --- | --- | --- | --- | + +## Decisions to make + +- **Which models are left.** Each group renames the models it migrates, polls first; this pass renames whatever remains, and the persistence `extra_data` columns with them. +- **How to stage the remaining renames.** Polls did a hard rename; decide whether the widely used models (`Message`, `ChannelModel`, `User`) get a deprecated `extraData` alias for a release. +- **What replaces `topLevelFields` and `Serializer`.** The v1 decoders still split flat JSON by a key list until the WebSocket moves to v2. A model that loses its JSON drops `topLevelFields` and its converter owns the list, as polls did; decide where `Serializer` goes once the last decoder does. + +## Risks + +- `Serializer`, the models' `topLevelFields` and the persistence `extra_data` columns name the concept too. +- A consumer reading `channel.extraData['hidden']` or `user.extraData['name']` loses the key; none in this repo. Filter fields over custom keys (`UserFilterField.email`) are unaffected. +- Runs after groups 09 and 11 at the earliest, once `User` and `ChannelModel` promote their parked fields. + +## Definition of done + +- [ ] No mapper under `lib/src/repository/mapper/` filters `custom`: `grep -rn _shadowedCustomKeys` is empty. +- [ ] No model writes a typed field into `extraData`, and no getter reads a typed field out of it. +- [ ] No public model exposes `topLevelFields`; each v1 decoder owns its key list. +- [ ] Every public model names its custom data the same way, in the models, the mappers, the v1 + decoders and the persistence columns. +- [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section. +- [ ] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. +- [ ] Decisions recorded in this file, and the status box ticked in `README.md`. diff --git a/openapi-migration/README.md b/openapi-migration/README.md index 25bf7b8325..3b6d92818b 100644 --- a/openapi-migration/README.md +++ b/openapi-migration/README.md @@ -16,7 +16,7 @@ generated operations in scope, the decisions that group has to make, its risks, | [02](02-devices.md) | Devices | 0 | 3 | ☑ | | [03](03-user-groups.md) | User Groups | 0 | 8 | ☑ | | [04](04-roles-guest-and-app.md) | Roles, Guest & App Settings | 0 | 5 | ☑ | -| [05](05-polls.md) | Polls | 13 | 13 | ☐ | +| [05](05-polls.md) | Polls | 0 | 13 | ☑ | | [06](06-reminders.md) | Message Reminders | 4 | 4 | ☐ | | [07](07-threads-and-drafts.md) | Threads & Drafts | 7 | 7 | ☐ | | [08](08-moderation-and-blocklists.md) | Moderation & Blocklists | 0 | 34 | ☑ | @@ -26,8 +26,9 @@ generated operations in scope, the decisions that group has to make, its risks, | [12](12-uploads-cdn.md) | Uploads (CDN) | 8 | 8 | ☑ | | [13](13-push-preferences.md) | Push Preferences | 1 | 1 | ☐ | | [14](14-banned-users.md) | Banned Users — split out of 08 | 1 | 1 | ☐ | +| [15](15-custom-data.md) | Custom Data — `extraData` is the wire's `custom`, then renamed | — | — | ☐ | -**Coverage:** 85 hand-written methods across 11 files, and all 129 generated operations, each claimed by exactly +**Coverage:** 71 hand-written methods across 9 files, and all 129 generated operations, each claimed by exactly one group. Verified mechanically — see [Keeping this plan honest](#keeping-this-plan-honest). @@ -81,7 +82,8 @@ breaks against v10: - envelopes are immutable, built through a const constructor rather than `late` setters; - `duration` is a non-nullable `String` on every envelope, where v10 typed it `String?`; - public models and envelopes are `@freezed`, so they compare by value; one that extended `Equatable` in v10 no - longer does, and loses `props`. + longer does, and loses `props`; +- a model's `extraData` is renamed `custom`, the wire's name for it (rule 9). Each ships the four artifacts listed under [Principles](#principles), like any other break. @@ -99,6 +101,18 @@ includes a named response that carries only `duration` today, such as `HideChann (see the breaks above). A field the server adds is exposed later, as an additive change. The one exception to "no JSON" is the temporary `@DataSerializable` storage codec in rule 8. + **A model uses freezed's `copyWith`.** It differs from a v10 hand-written one: `null` clears a nullable field + instead of keeping it, `null` re-runs a constructor default (`id ?? Uuid().v4()`), and a constructor parameter + with no field of the same name and type is not generated. Before deleting the v10 method, run the audit in + [`.claude/skills/openapi-migration/copywith-audit.md`](../.claude/skills/openapi-migration/copywith-audit.md) + and handle every site it reports: a compile break gets a local or a `?? model.field`; a "kept on null; check" + site is rewritten as an explicit merge (`?? this.x` at the caller, or an `@internal` merge method in the shape of + `Poll.withTranslationsOf`) unless the clear is the intended behaviour. When the audit's verdict says freezed + cannot replace the method — a parameter backed by a getter, a private field or a different type, as on + `Message` — keep `@Freezed(copyWith: false)` with the hand-written method, or reshape the constructor as a + public API change of its own. The semantics change goes in the CHANGELOG and the guide, listed per field under + the rows of the audit's table. + **Names default to v10's,** even where the generated name differs: `OGAttachmentResponse` rather than `GetOGResponse`, `AppSettings` rather than `AppResponseFields`, `UploadConfig` rather than `FileUploadConfig`. That is a default, not a requirement. The user may ask for another name, or a migration may propose one that @@ -135,6 +149,14 @@ includes a named response that carries only `duration` today, such as `HideChann parent's rule-7 converter writes it. The codec is temporary: every use is marked `// TODO(openapi-migration): remove in group 10` and listed below, and group 10 replaces every use and deletes the typedef, so no later group adds one. The cache is disposable, so the stored format is ours to choose. +9. **A migrated model names its custom data `custom`, and it is the wire's `custom`, verbatim, in both + directions.** A response mapper copies the generated `custom` map into it and a request mapper sends it back as + `custom`; neither filters it. A model whose group has not run still says `extraData`. A field the + generated response types is never parked in `extraData` behind a getter: it becomes a field on the model, as + the owning group's break, or stays out. `ChannelModel.hidden` and `User.name` are parked today, and groups 11 + and 09 promote them. `topLevelFields` and `Serializer` serve only the v1 decoders, which still split flat JSON + by a key list, and go with the WebSocket v2 move; a model that loses its JSON drops `topLevelFields` and its + converter owns the list. A v1 writer leaves a custom entry named like a key out so it cannot overwrite the key. ### Temporary adapters @@ -142,7 +164,9 @@ includes a named response that carries only `duration` today, such as `HideChann | --- | --- | --- | | `DeviceV1JsonConverter` | `OwnUser.devices` | [09](09-users.md) | | `userGroupsFromV1Json` | `Message.mentionedGroups` | [10](10-messages.md) | -| `DataSerializable` | `UserGroup`, `UserGroupMember` (`fromData`, `toData`) | [10](10-messages.md) | +| `DataSerializable` | `UserGroup`, `UserGroupMember`, `PollOption` (`fromData`, `toData`) | [10](10-messages.md) | +| `PollV1JsonConverter` | `Message.poll`, `DraftMessage.poll`, `Event.poll` | WebSocket v2 (no group) | +| `PollVoteV1JsonConverter` | `Event.pollVote` | WebSocket v2 (no group) | | `user_mapper.dart` (kept, re-pointed) | today's `User`, which still reads and writes JSON | [09](09-users.md) | How v1 JSON decodes `User` once it becomes a plain model is decided in [01-foundation](01-foundation.md): until @@ -162,11 +186,15 @@ surfaces before it reaches `Message` and `ChannelState`: - **08** is where we decide what *not* to expose: 34 generated operations against 10 hand-written methods. - **14** is `queryBannedUsers`, split out of 08 because it is the only moderation call that answers with a model. The `User` mappers it needs landed with group 04. -- **10–11** are the core of the SDK, and carry the `custom` / `extraData` promotion problem. +- **10–11** are the core of the SDK; 11 promotes the channel flags parked in `extraData` (rule 9). - **12** comes late because it needs its own hand-written multipart client and is the highest-traffic path in the SDK. - **09** is last. Every group before it maps users through `user_mapper.dart` onto today's `User`; 09 migrates the user endpoints and restructures `User` and `OwnUser` themselves, when every parent that embeds them has moved. +- **15** is the custom-data pass: every model's custom data is named `custom` and is the wire's `custom`, with no + mapper filtering it and no typed field parked in it. Each group renames the models it migrates (polls went + first); 15 renames whatever is left and the persistence columns, and runs after 09 and 11, which promote their + parked fields. ## Prerequisites diff --git a/openapi-migration/tool/generate_plan.py b/openapi-migration/tool/generate_plan.py index 151e2843f3..fdc87e02de 100644 --- a/openapi-migration/tool/generate_plan.py +++ b/openapi-migration/tool/generate_plan.py @@ -76,6 +76,8 @@ def match(path): hand-written, with the reason. - [ ] Public methods return `Future>`; no `getOrThrow()` inside the SDK. - [ ] Hand-written request/response DTOs for this group are deleted, or their retention is justified. + - [ ] `copywith_audit.py` was run for every model that loses a v10 `copyWith`, every site it reports is + handled, and the semantics change is in the CHANGELOG and the guide ([README rule 2](README.md#domain-models)). - [ ] `melos run analyze` clean, `melos run test:dart` green, persistence tests green if this group persists anything. - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section for every break. @@ -307,19 +309,77 @@ def match(path): ), dict( num='05', slug='polls', title='Polls', - hand=['polls_api.dart'], + hand=[], match=owns('/api/v2/polls', '/api/v2/chat/messages/{message_id}/polls'), goal='First group with real domain models and persistence behind it.', - decisions=[ - '`Poll`, `PollOption` and `PollVote` are public and persisted; adopting generated shapes means ' - 'touching `stream_chat_persistence` in the same PR.', - '`VotingVisibility` is ours; the generated equivalent is an inline per-operation enum.', - 'One generated `PollResponse` answers `createPoll`, `getPoll`, `updatePoll` and `updatePollPartial`, ' - 'where v10 has `CreatePollResponse`, `GetPollResponse` and `UpdatePollResponse`. The v10 envelopes ' - 'stay; decide with the user how the mapper names its conversions, since one `toModel()` cannot return ' - 'three types. `PollOptionResponse` (create, get, update an option) and `PollVoteResponse` (cast, remove ' - 'a vote) have the same shape.', - ], + decisions=[], + taken=textwrap.dedent("""\ + - **`Poll`, `PollOption` and `PollVote` keep their v10 names, fields and defaults,** as `@freezed` + classes with no JSON, mapped from `PollResponseData`, `PollOptionResponseData` and + `PollVoteResponseData` in `lib/src/repository/mapper/polls_mapper.dart`. The uuid default `id` and the + `DateTime.now()` timestamps stay; `PollVote.isAnswer` becomes a getter, and `latestVotes`, `ownVotes` and + `ownAnswers` stay `late final` caches. + - **Equality now includes `extraData`.** v10's `Equatable` props left it out of `Poll` and `PollOption`; + freezed compares every field, and so does `Message`, whose props include its poll. Accepted as a + behavioural break and flagged for review. + - **The eight v10 envelopes collapse to the three the server returns:** `PollResponse` (create, get, + update, partial update), `PollOptionResponse` (create, get, update an option) and `PollVoteResponse` + (cast, remove a vote), each with one `toModel()`. `QueryPollsResponse` and `QueryPollVotesResponse` + keep their names. A rename break beyond the [sanctioned ones](README.md#domain-models), approved for + this group and flagged for review. `PollVoteResponse.vote` is nullable, as the spec marks it. + - **The fields only the generated types carry stay out:** the `*_i18n` maps, `is_answer` (derived from + `answerText`) and `PollVoteResponse.poll`. Adding them is additive. The i18n keys are known fields + (`topLevelFields`), so neither the mapper nor the v1 decoder puts them in `extraData`, and a poll + compares equal whichever path delivered it. + - **Write methods keep taking `Poll` and `PollOption`.** The mapper sends only the writable settings; + the vote summary, `createdBy` and timestamps are not sent. Dedicated request types were considered + and rejected: the draft a caller builds must stay a `Poll`, because a message carries one. Flagged + for review. + - **Option ids are not sent on create.** `PollOptionInput` has none; the server assigns them and + rejects a client id as reserved (confirmed live: v10 answered 400). **`updatePoll` and + `updatePollOption` send an option without an id with an empty one** and return the server's failure; + the SDK does not pre-check a rule the backend owns. + - **`queryPolls` and `queryPollVotes` take `limit`, `next` and `prev`** instead of `PaginationParams`. + The generated requests carry only those, and the backend never read the offset or id/date cursors on + these queries. `limit` defaults to 10, `PaginationParams`' default, so an omitted limit pages as in + v10. The responses expose `prev`, so the parameter is usable. Flagged for review. + - **`VotingVisibility` becomes an extension type over its wire string,** following `PushProvider`. An + unknown value is kept rather than rejected, in the mapper, the v1 decoder and persistence. + - **An answer's `optionId` stays `""`,** as the server sends it and v10 decoded it. + - **`custom` is `extraData`, verbatim, in both directions** (README rule 9). The response mappers copy the + generated `custom` map as is and the request mappers send `extraData` as `custom`; an entry named like + one of the model's own fields is custom data on v2, where v10's flattened body let it set the field. + The v1 converters own the key lists that split the flat JSON on read (`Poll.topLevelFields` and + `PollOption.topLevelFields` are removed), and leave such an entry out on write so it cannot overwrite + a key. + - **Every public poll method returns a `Result`,** on `StreamChatClient` and `Channel`. `deletePoll` and + `deletePollOption` answer `DurationResponse` and return `Result`. `closePoll` stays a + `partialUpdatePoll` that sets `is_closed`. + - **`Channel.sendPoll` returns `Result`.** A `createPoll` failure passes through + and no message is sent; a `StreamException` thrown by `sendMessage` (still v1) is caught into a + `Failure`. + - **A missing option or vote id is a `Failure(StreamClientException)`, not an `ArgumentError`,** in + `Channel.castPollVote` and `removePollVote`, matching the migrated never-throws contract. + - **v1 JSON keeps decoding through hand-written converters** (`PollV1JsonConverter`, + `PollVoteV1JsonConverter`) on `Message.poll`, `DraftMessage.poll`, `Event.poll` and `Event.pollVote`. + The WebSocket is v1-only and sends custom data flat, with no `custom` key, so the generated + `fromJson` would throw on it; the converters mirror v10's `fromJson` and tolerate missing keys. + `Event` writes these fields too, so its converters run both ways. + - **`PollOption` gets the `@DataSerializable` codec** for `polls.options`. The stored format nests + custom data under `extra_data` where v10 flattened it, so `schemaVersion` is bumped. + - **`Poll`, `PollOption` and `PollVote` use freezed's `copyWith`.** The audit found one lib site + (`stream_message_preview_text.dart`, a `String?` passed for `name`) and one test per model; the + merging of poll events in `channel_state_mutations.dart` and `Poll.withTranslationsOf` passes no + argument that is null while the field is set, so it is unchanged. `null` now clears a nullable field + and re-applies the `id` and timestamp defaults, recorded as a break. + - **`extraData` is renamed `custom` on `Poll` and `PollOption`,** the wire's name, with no deprecated + alias; `StreamPollController.addOption` and `updateOption` and the `polls.custom` column follow. + Every migrated model takes the name from here on (README rule 9); [group 15](15-custom-data.md) + renames whatever no group migrates. + - **Verified live** against the demo app with a before/after harness covering every endpoint, the + error cases and the WebSocket events, and in the sample app on web. + """), + done=DONE.replace('- [ ]', '- [x]'), risks=[ 'Vote operations live under `/chat/messages/{message_id}/polls/...`, not `/polls` — easy to miss when ' 'grepping by path.', @@ -354,6 +414,9 @@ def match(path): 'because the two groups can otherwise disagree.', ], risks=[ + '`DraftMessage.poll` is never decoded from JSON: `poll` is not in `DraftMessage.topLevelFields`, so the key ' + 'would land in `extraData`, and the draft payload carries `poll_id` only. Its `PollV1JsonConverter` exists so ' + 'the generated code compiles. Decide here whether the field stays constructor-only or goes.', 'The four draft methods live in `message_api.dart`, not `threads_api.dart` — this group reaches into ' 'that file, and group 10 must leave those four alone.', '`Draft` and `DraftMessage` are public, persisted, and read by `StreamDraftListController` in ' @@ -509,9 +572,12 @@ def match(path): 'restructures them, last: the mappers in `user_mapper.dart` already map the generated types onto the ' 'current class for every group before it, and stay. See [01-foundation](01-foundation.md).', '`PrivacySettings` and the push-preference sub-shapes — decide per type.', - '`UserResponse.toModel()` leaves `OwnUser.topLevelFields`, `deleted_at`, `deactivated_at` and ' - '`revoke_tokens_issued_before` out of every user\'s `extraData` (`_shadowedCustomKeys`), where v1 kept the ' - '`OwnUser`-only keys in a plain user\'s `extraData`. Revisit once the mapper serves plain users.', + '**`User.name` and `image` become fields** (README rule 9): `UserResponse` types them, and the constructor ' + 'stops writing them into `extraData`; decide whether `name` keeps its fallback to `id`. `deleted_at`, ' + '`deactivated_at`, `revoke_tokens_issued_before` and `blocked_user_ids`, which `UserResponse` types and v1 ' + 'kept in a plain user\'s `extraData`, become fields or are dropped, never `extraData`. ' + '`UserResponse.toModel()` then copies `custom` verbatim into the field, renamed `custom`, and ' + '`UserRequestMapper` stops reading `extraData[\'name\']` and `[\'image\']`.', '`UserFilterField.shadowBanned` and `.bypassModeration` read `extraData`, which the generated ' '`UserResponse` has no field to fill.', '`updateUsers` reuses `User.toRequest()`. It is a full upsert, and v10\'s flattened body filed the ' @@ -529,12 +595,19 @@ def match(path): 'had to drop from `MuteUsersResponse`: the `mutes` the call created and the `ownUser` it left ' 'behind. Adding them is additive for anyone reading the response, so revisit them here rather ' 'than leaving them dropped for good.', + 'Until then, a user mapped from `UserResponse` and the same user decoded from v1 JSON carry different ' + '`extraData`: the v1 path keeps `blocked_user_ids`, `deleted_at`, `deactivated_at` and ' + '`revoke_tokens_issued_before`, the mapper drops them. Equality includes `extraData`, so a `Poll` from a ' + 'REST call (its `createdBy`, its votes\' `user`) and the same poll from an event compare unequal.', ], done=DONE + ( '- [ ] Temporary adapters owned by this group (`DeviceV1JsonConverter`) are deleted and removed from\n' ' the table in `README.md`.\n' '- [ ] `user_mapper.dart` maps onto the restructured `User`, and its `TODO(openapi-migration)` note is\n' ' gone.\n' + '- [ ] A user mapped from `UserResponse` and the same user decoded from v1 / WebSocket JSON compare equal.\n' + '- [ ] `user_mapper.dart` has no `_shadowedCustomKeys`: `User.custom` is the wire\'s `custom`, verbatim, and\n' + ' no typed field is written into it.\n' ), ), dict( @@ -575,13 +648,13 @@ def match(path): decisions=[ '`ChannelState`, `ChannelModel` and `Member` are public, persisted, and rebuilt from WebSocket ' 'events. Keep ours and map.', - '**Whether `ChannelModel` promotes its `extraData`-backed flags to real fields.** `disabled`, ' - '`hidden`, `muted`, `blocked` and `truncatedAt` all arrive as root fields on `ChannelResponse` but ' - 'are pushed into `extraData` and read back through getters, which the constructor comment calls ' - '"for backwards compatibility". Promoting them is a break worth making in v11 if it is made at ' - 'all, and it belongs with this group\'s model shape rather than with whichever phase happens to ' - 'add the next flag. Raised on ' - '[#2958](https://github.com/GetStream/stream-chat-flutter/pull/2958).', + '**`ChannelModel` promotes its `extraData`-backed flags to fields** (README rule 9). `disabled`, ' + '`hidden`, `muted`, `blocked` and `truncatedAt`, plus the keys the channel mapper parks today — ' + '`truncatedBy`, `muteExpiresAt`, `hideMessagesBefore`, `autoTranslationEnabled` and ' + '`autoTranslationLanguage` — become fields with persistence columns, and the constructor stops writing ' + 'them into the custom data, which is renamed `custom`. `name` and `image` stay in it: `ChannelResponse` ' + 'has no such fields. ' + 'Raised on [#2958](https://github.com/GetStream/stream-chat-flutter/pull/2958).', '`sync` returns `SyncResponse` — one of the two models that needed the WSEvent generator patch. ' 'Verify it decodes before relying on it.', ], @@ -590,7 +663,8 @@ def match(path): 'into that file.', '`queryChannels` drives the channel list controllers and the offline cache; a shape change here is ' 'felt everywhere.', - 'Channel `custom`/`extraData` promotion, same class of problem as messages.', + 'A consumer reading `channel.extraData[\'hidden\']` and friends loses the key once the flags are fields; ' + 'none in this repo.', 'Landing `ChannelResponse` -> `ChannelModel` is the last thing blocking ' '[08](08-moderation-and-blocklists.md)\'s `muteChannel`, which drops `channelMute`, `channelMutes` ' 'and `ownUser` because the generated `ChannelMute` carries a `ChannelResponse?` and a ' @@ -598,6 +672,10 @@ def match(path): 'Decide the null case there too — ours are non-nullable, the generated ones are not, the same ' 'question [14](14-banned-users.md) records for `BanResponse.user`.', ], + done=DONE + ( + '- [ ] `channel_mapper.dart` has no `_shadowedCustomKeys`: `ChannelModel.custom` is the wire\'s `custom`,\n' + ' verbatim, and no flag is written into it.\n' + ), ), dict( num='12', slug='uploads-cdn', title='Uploads (CDN)', @@ -699,6 +777,39 @@ def match(path): '`StreamChatApi.moderation` getter.', ], ), + dict( + num='15', slug='custom-data', title='Custom Data', + hand=[], + match=owns(), + goal='Every public model\'s custom data is named `custom` and is exactly the wire\'s `custom`.', + decisions=[ + '**Which models are left.** Each group renames the models it migrates, polls first; this pass renames ' + 'whatever remains, and the persistence `extra_data` columns with them.', + '**How to stage the remaining renames.** Polls did a hard rename; decide whether the widely used models ' + '(`Message`, `ChannelModel`, `User`) get a deprecated `extraData` alias for a release.', + '**What replaces `topLevelFields` and `Serializer`.** The v1 decoders still split flat JSON by a key ' + 'list until the WebSocket moves to v2. A model that loses its JSON drops `topLevelFields` and its ' + 'converter owns the list, as polls did; decide where `Serializer` goes once the last decoder does.', + ], + done=textwrap.dedent("""\ + - [ ] No mapper under `lib/src/repository/mapper/` filters `custom`: `grep -rn _shadowedCustomKeys` is empty. + - [ ] No model writes a typed field into `extraData`, and no getter reads a typed field out of it. + - [ ] No public model exposes `topLevelFields`; each v1 decoder owns its key list. + - [ ] Every public model names its custom data the same way, in the models, the mappers, the v1 + decoders and the persistence columns. + - [ ] `migrations/v11-migration.md`: Symbol Map rows plus a feature section. + - [ ] CHANGELOG entry under `🛑️ Breaking` for each break; PR title `refactor(llc)!:`. + - [ ] Decisions recorded in this file, and the status box ticked in `README.md`. + """), + risks=[ + '`Serializer`, the models\' `topLevelFields` and the persistence `extra_data` columns name the ' + 'concept too.', + 'A consumer reading `channel.extraData[\'hidden\']` or `user.extraData[\'name\']` loses the key; none in ' + 'this repo. Filter fields over custom keys (`UserFilterField.email`) are unaffected.', + 'Runs after groups 09 and 11 at the earliest, once `User` and `ChannelModel` promote their parked ' + 'fields.', + ], + ), ] diff --git a/packages/stream_chat/CHANGELOG.md b/packages/stream_chat/CHANGELOG.md index 35bbfb5d5e..a31e8f652a 100644 --- a/packages/stream_chat/CHANGELOG.md +++ b/packages/stream_chat/CHANGELOG.md @@ -78,6 +78,19 @@ - `AppSettings` and `UploadConfig` no longer extend `Equatable`, so `props` is removed; they still compare by value and gain `copyWith`. - `StreamChatApi.general.getAppSettings` is removed; call `StreamChatClient.getAppSettings` instead. - `StreamChatApi.guest` and `ConnectGuestUserResponse` are removed; call `StreamChatClient.connectGuestUser` instead. +- The poll calls on `StreamChatClient` and `Channel` return a `Result` instead of throwing. +- The eight poll response types are replaced by `PollResponse`, `PollOptionResponse` and `PollVoteResponse`. +- `queryPolls` and `queryPollVotes` take `limit` (10 when omitted) and `next` instead of `PaginationParams`. +- `Channel.castPollVote` and `removePollVote` return a failure instead of throwing an `ArgumentError` for an option or vote without an id. +- `VotingVisibility` is an extension type over its wire string instead of an enum. +- `Poll`, `PollOption`, `PollVote` and the poll responses no longer decode from or encode to JSON; `PollOption` gains `fromData` and `toData`, which read and write only the format `stream_chat_persistence` stores it in. +- The poll responses are immutable, built through a const constructor, and their `duration` is a non-nullable `String`; `PollVoteResponse.vote` is nullable. +- `Poll`, `PollOption` and `PollVote` no longer extend `Equatable`, so `props` is removed; they still compare by value, now including `custom`. +- `Poll.copyWith`, `PollOption.copyWith` and `PollVote.copyWith` set a nullable field passed as `null` to `null` instead of keeping its value, and give `Poll.id` and the `createdAt` and `updatedAt` timestamps a fresh default when passed `null`. +- `StreamChatApi.polls` is removed; its endpoints are reached through `StreamChatClient`. +- `Poll.topLevelFields` and `PollOption.topLevelFields` are removed. +- `Poll.extraData` and `PollOption.extraData` are renamed `custom`. +- `Poll.custom` and `PollOption.custom` hold the custom data exactly as the API returns it, and the poll and poll option requests send it back as custom data, so an entry named like one of the poll's or option's own fields no longer sets that field. - `sendImage`, `sendFile`, `uploadImage` and `uploadFile`, on `StreamChatClient`, `Channel` and `AttachmentFileUploader`, return a `Result` instead of throwing; read the URL from `fileUrl` and a video's thumbnail from `thumbUrl`. - `deleteImage`, `deleteFile`, `removeImage` and `removeFile` return a `Result` instead of throwing, and carry no value on success. - `SendAttachmentResponse`, `SendFileResponse`, `SendImageResponse`, `UploadImageResponse` and `UploadFileResponse` are removed in favor of `UploadedFile`. @@ -112,6 +125,7 @@ - Added optional `reason` and `custom` arguments to `flagMessage` and `flagUser`, recorded with the flag. - Added an optional `timeout` to `StreamChatClient.moderation.muteUser` that expires the mute. - Added `StreamChatClient.moderation.muteUsers` and `unmuteUsers`, which mute and unmute several users in one call and report the ids that matched no user. +- Added a `prev` argument to `queryPolls` and `queryPollVotes`, and a `prev` cursor on their responses, for paging backwards. 🔒 Security diff --git a/packages/stream_chat/lib/src/client/channel/channel.dart b/packages/stream_chat/lib/src/client/channel/channel.dart index 84f41d684c..62c7ac30dc 100644 --- a/packages/stream_chat/lib/src/client/channel/channel.dart +++ b/packages/stream_chat/lib/src/client/channel/channel.dart @@ -1429,44 +1429,63 @@ class Channel { final _pollLock = Lock(); - /// Send a message with a poll to this channel. + /// Creates [poll] and sends it to this channel in a new message. /// - /// Optionally provide a [messageText] to send a message along with the poll. - Future sendPoll( + /// The message carries [messageText] as its text, if given. + /// + /// If the poll cannot be created, no message is sent and the failure is + /// returned. If the message cannot be sent, the poll stays created and the + /// failure is returned. + Future> sendPoll( Poll poll, { String? messageText, }) async { _checkInitialized(); - final res = await _pollLock.synchronized(() => _client.createPoll(poll)); - return sendMessage( - Message( - text: messageText, - poll: res.poll, - pollId: res.poll.id, - ), - ); + final created = await _pollLock.synchronized(() => _client.createPoll(poll)); + return created.flatMapAsync((response) => _sendPollMessage(response.poll, messageText)); } - /// Updates the [poll] in this channel. - Future updatePoll(Poll poll) { + // TODO(openapi-migration): remove once sendMessage returns a Result + Future> _sendPollMessage(Poll poll, String? messageText) async { + try { + final message = Message(text: messageText, poll: poll, pollId: poll.id); + return Result.success(await sendMessage(message)); + } on StreamException catch (error, stackTrace) { + return Result.failure(error, stackTrace); + } catch (error, stackTrace) { + final exception = StreamClientException(message: 'Failed to send the poll message', cause: error); + return Result.failure(exception, stackTrace); + } + } + + /// Updates [poll] to match the settings and options it carries. + /// + /// [Poll.options] becomes the poll's full list of options, each matched by + /// its id, so an existing option missing from it is deleted. New options are + /// added through [createPollOption]. + /// + /// [Poll.enforceUniqueVote] is left as the poll was created. + Future> updatePoll(Poll poll) { _checkInitialized(); return _pollLock.synchronized(() => _client.updatePoll(poll)); } - /// Deletes the given [poll] from this channel. - Future deletePoll(Poll poll) { + /// Deletes [poll]. + Future> deletePoll(Poll poll) { _checkInitialized(); return _pollLock.synchronized(() => _client.deletePoll(poll.id)); } - /// Close the given [poll]. - Future closePoll(Poll poll) { + /// Closes [poll], so it accepts no more votes or answers. + Future> closePoll(Poll poll) { _checkInitialized(); return _pollLock.synchronized(() => _client.closePoll(poll.id)); } - /// Create a new poll option for the given [poll]. - Future createPollOption( + /// Adds [option] to [poll]. + /// + /// The option gets a new id, which the returned option carries. + Future> createPollOption( Poll poll, PollOption option, ) { @@ -1478,8 +1497,12 @@ class Channel { final _pollVoteLock = Lock(); - /// Cast a vote on the given [poll] with the given [option]. - Future castPollVote( + /// Votes for [option] of [poll], which was sent in [message]. + /// + /// If the poll allows only one vote per user, the vote replaces the previous + /// one. The [option] must carry its id; if it does not, the call returns a + /// failure without voting. + Future> castPollVote( Message message, Poll poll, PollOption option, @@ -1488,7 +1511,9 @@ class Channel { final optionId = option.id; if (optionId == null) { - throw ArgumentError('Option id cannot be null'); + return const Result.failure( + StreamClientException(message: 'The poll option has no id, so it cannot be voted for.'), + ); } return _pollVoteLock.synchronized( @@ -1500,8 +1525,10 @@ class Channel { ); } - /// Add a new answer to the given [poll]. - Future addPollAnswer( + /// Adds [answerText] as an answer to [poll], which was sent in [message]. + /// + /// Each user has one answer per poll; a new one replaces the previous one. + Future> addPollAnswer( Message message, Poll poll, { required String answerText, @@ -1516,17 +1543,20 @@ class Channel { ); } - /// Remove a vote on the given [poll] with the given [vote]. - Future removePollVote( + /// Removes [vote], a vote or answer on [poll], which was sent in [message]. + /// + /// The [vote] must carry its id; if it does not, the call returns a failure + /// without removing anything. + Future> removePollVote( Message message, Poll poll, PollVote vote, - ) { + ) async { _checkInitialized(); final voteId = vote.id; if (voteId == null) { - throw ArgumentError('Vote id cannot be null'); + return const Result.failure(StreamClientException(message: 'The poll vote has no id, so it cannot be removed.')); } return _pollVoteLock.synchronized( @@ -1538,20 +1568,29 @@ class Channel { ); } - /// Query the poll votes for the given [pollId] with the given [filter] and - /// [sort] options. - Future queryPollVotes( + /// Fetches one page of the votes and answers matching [filter] on the poll + /// with the id [pollId], ordered by [sort]. + /// + /// Up to [limit] votes are returned. The next page is fetched by passing a + /// response's [QueryPollVotesResponse.next] as [next], and the previous page + /// by passing its [QueryPollVotesResponse.prev] as [prev]. At most one of + /// [next] and [prev] may be given. + Future> queryPollVotes( String pollId, { PollVoteFilter? filter, List? sort, - PaginationParams pagination = const PaginationParams(), + int limit = 10, + String? next, + String? prev, }) { _checkInitialized(); return _client.queryPollVotes( pollId, filter: filter, sort: sort, - pagination: pagination, + limit: limit, + next: next, + prev: prev, ); } diff --git a/packages/stream_chat/lib/src/client/client.dart b/packages/stream_chat/lib/src/client/client.dart index c7085d23f4..ab60fc33b7 100644 --- a/packages/stream_chat/lib/src/client/client.dart +++ b/packages/stream_chat/lib/src/client/client.dart @@ -67,6 +67,11 @@ import '../core/models/response/get_user_group_response.dart'; import '../core/models/response/list_devices_response.dart'; import '../core/models/response/list_user_groups_response.dart'; import '../core/models/response/og_attachment_response.dart'; +import '../core/models/response/poll_option_response.dart'; +import '../core/models/response/poll_response.dart'; +import '../core/models/response/poll_vote_response.dart'; +import '../core/models/response/query_poll_votes_response.dart'; +import '../core/models/response/query_polls_response.dart'; import '../core/models/response/remove_user_group_members_response.dart'; import '../core/models/response/search_roles_response.dart'; import '../core/models/response/search_user_groups_response.dart'; @@ -84,6 +89,7 @@ import '../repository/app_settings_repository.dart'; import '../repository/devices_repository.dart'; import '../repository/general_repository.dart'; import '../repository/moderation_repository.dart'; +import '../repository/polls_repository.dart'; import '../repository/roles_repository.dart'; import '../repository/user_groups_repository.dart'; import '../ws/connect_request.dart'; @@ -187,6 +193,7 @@ class StreamChatClient { _rolesRepository = RolesRepository(api); _devicesRepository = DevicesRepository(api); _userGroupsRepository = UserGroupsRepository(api); + _pollsRepository = PollsRepository(api); _generalRepository = GeneralRepository(api); _moderationRepository = ModerationRepository(api); _appSettingsManager = AppSettingsManager(AppSettingsRepository(api)); @@ -230,6 +237,7 @@ class StreamChatClient { late final RolesRepository _rolesRepository; late final DevicesRepository _devicesRepository; late final UserGroupsRepository _userGroupsRepository; + late final PollsRepository _pollsRepository; late final GeneralRepository _generalRepository; late final ModerationRepository _moderationRepository; late final AppSettingsManager _appSettingsManager; @@ -1634,115 +1642,137 @@ class StreamChatClient { threadId, ); - /// Creates a new Poll - Future createPoll(Poll poll) => _chatApi.polls.createPoll(poll); + /// Creates [poll] with its settings and options. + /// + /// Any ids already set on the options are ignored; every option gets a new + /// one, which the returned poll carries. + /// + /// A poll is shown to the members of a channel once it is sent in a message; + /// [Channel.sendPoll] does both. + Future> createPoll(Poll poll) => _pollsRepository.createPoll(poll); - /// Retrieves a Poll by [pollId] - Future getPoll(String pollId) => _chatApi.polls.getPoll(pollId); + /// Fetches the poll with the id [pollId]. + Future> getPoll(String pollId) => _pollsRepository.getPoll(pollId); - /// Updates a Poll - Future updatePoll(Poll poll) => _chatApi.polls.updatePoll(poll); + /// Updates [poll] to match the settings and options it carries. + /// + /// [Poll.options] becomes the poll's full list of options, each matched by + /// its id, so an existing option missing from it is deleted. New options are + /// added through [createPollOption]. + /// + /// [Poll.enforceUniqueVote] is left as the poll was created. + Future> updatePoll(Poll poll) => _pollsRepository.updatePoll(poll); - /// Partially updates a Poll by [pollId]. + /// Changes some of the settings of the poll with the id [pollId], leaving the + /// others as they are. /// - /// Use [set] to define values to be set. - /// Use [unset] to define values to be unset. - Future partialUpdatePoll( + /// Only [Poll.name], [Poll.description], [Poll.votingVisibility], + /// [Poll.maxVotesAllowed], [Poll.allowAnswers] and [Poll.isClosed] can be + /// changed through [set]. Any other key, including those of the other + /// settings, is stored in the poll's custom data. + /// + /// The keys named in [unset] are removed from the custom data only. To remove + /// the vote limit, set it to null instead. + Future> partialUpdatePoll( String pollId, { Map? set, List? unset, - }) => _chatApi.polls.partialUpdatePoll( - pollId, - set: set, - unset: unset, - ); + }) => _pollsRepository.partialUpdatePoll(pollId, set: set, unset: unset); - /// Deletes the Poll by [pollId]. - Future deletePoll(String pollId) => _chatApi.polls.deletePoll(pollId); + /// Deletes the poll with the id [pollId]. + Future> deletePoll(String pollId) => _pollsRepository.deletePoll(pollId); - /// Marks the Poll [pollId] as closed. - Future closePoll(String pollId) => partialUpdatePoll( - pollId, - set: { - 'is_closed': true, - }, - ); + /// Closes the poll with the id [pollId], so it accepts no more votes or + /// answers. + Future> closePoll(String pollId) => partialUpdatePoll(pollId, set: {'is_closed': true}); - /// Creates a new Poll Option for the Poll [pollId]. - Future createPollOption( + /// Adds [option] to the poll with the id [pollId]. + /// + /// The option gets a new id, which the returned option carries. + Future> createPollOption( String pollId, PollOption option, - ) => _chatApi.polls.createPollOption(pollId, option); + ) => _pollsRepository.createPollOption(pollId, option); - /// Retrieves a Poll Option by [optionId] for the Poll [pollId]. - Future getPollOption( + /// Fetches an option of a poll, identified by [optionId] and [pollId]. + Future> getPollOption( String pollId, String optionId, - ) => _chatApi.polls.getPollOption(pollId, optionId); + ) => _pollsRepository.getPollOption(pollId, optionId); - /// Updates a Poll Option for the Poll [pollId]. - Future updatePollOption( + /// Replaces the text and custom data of [option], an existing option of the + /// poll with the id [pollId]. + /// + /// The [option] is found by its id. + Future> updatePollOption( String pollId, PollOption option, - ) => _chatApi.polls.updatePollOption(pollId, option); + ) => _pollsRepository.updatePollOption(pollId, option); - /// Deletes a Poll Option by [optionId] for the Poll [pollId]. - Future deletePollOption( + /// Removes an option from a poll, identified by [optionId] and [pollId]. + Future> deletePollOption( String pollId, String optionId, - ) => _chatApi.polls.deletePollOption(pollId, optionId); + ) => _pollsRepository.deletePollOption(pollId, optionId); - /// Cast a [vote] for the Poll [pollId]. - Future castPollVote( + /// Votes for an option of a poll sent in a message, identified by [optionId], + /// [pollId] and [messageId]. + /// + /// If the poll allows only one vote per user, the vote replaces the previous + /// one. + Future> castPollVote( String messageId, String pollId, { required String optionId, - }) { - final vote = PollVote(optionId: optionId); - return _chatApi.polls.castPollVote(messageId, pollId, vote); - } + }) => _pollsRepository.castPollVote(messageId, pollId, optionId: optionId); - /// Adds a answer with [answerText] for the Poll [pollId]. - Future addPollAnswer( + /// Adds [answerText] as an answer to a poll sent in a message, identified by + /// [pollId] and [messageId]. + /// + /// Each user has one answer per poll; a new one replaces the previous one. + Future> addPollAnswer( String messageId, String pollId, { required String answerText, - }) { - final vote = PollVote(answerText: answerText); - return _chatApi.polls.castPollVote(messageId, pollId, vote); - } + }) => _pollsRepository.addPollAnswer(messageId, pollId, answerText: answerText); - /// Removes a vote by [voteId] for the Poll [pollId]. - Future removePollVote( + /// Removes a vote or answer from a poll sent in a message, identified by + /// [voteId], [pollId] and [messageId]. + Future> removePollVote( String messageId, String pollId, String voteId, - ) => _chatApi.polls.removePollVote(messageId, pollId, voteId); + ) => _pollsRepository.removePollVote(messageId, pollId, voteId); - /// Queries Polls with the given [filter] and [sort] options. - Future queryPolls({ + /// Fetches one page of the polls matching [filter], ordered by [sort]. + /// + /// Up to [limit] polls are returned. The next page is fetched by passing a + /// response's [QueryPollsResponse.next] as [next], and the previous page by + /// passing its [QueryPollsResponse.prev] as [prev]. At most one of [next] and + /// [prev] may be given. + Future> queryPolls({ PollFilter? filter, List? sort, - PaginationParams pagination = const PaginationParams(), - }) => _chatApi.polls.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ); - - /// Queries Poll Votes for the Poll [pollId] with the given [filter] - /// and [sort] options. - Future queryPollVotes( + int limit = 10, + String? next, + String? prev, + }) => _pollsRepository.queryPolls(filter: filter, sort: sort, limit: limit, next: next, prev: prev); + + /// Fetches one page of the votes and answers matching [filter] on the poll + /// with the id [pollId], ordered by [sort]. + /// + /// Up to [limit] votes are returned. The next page is fetched by passing a + /// response's [QueryPollVotesResponse.next] as [next], and the previous page + /// by passing its [QueryPollVotesResponse.prev] as [prev]. At most one of + /// [next] and [prev] may be given. + Future> queryPollVotes( String pollId, { PollVoteFilter? filter, List? sort, - PaginationParams pagination = const PaginationParams(), - }) => _chatApi.polls.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ); + int limit = 10, + String? next, + String? prev, + }) => _pollsRepository.queryPollVotes(pollId, filter: filter, sort: sort, limit: limit, next: next, prev: prev); /// Update or Create the given user object. Future updateUser(User user) => updateUsers([user]); diff --git a/packages/stream_chat/lib/src/core/api/polls_api.dart b/packages/stream_chat/lib/src/core/api/polls_api.dart deleted file mode 100644 index edb6059d6f..0000000000 --- a/packages/stream_chat/lib/src/core/api/polls_api.dart +++ /dev/null @@ -1,197 +0,0 @@ -import 'dart:convert'; - -import '../http/stream_http_client.dart'; -import '../models/poll.dart'; -import '../models/poll_option.dart'; -import '../models/poll_vote.dart'; -import 'requests.dart'; -import 'responses.dart'; - -/// Defines the api dedicated to polls operations -class PollsApi { - /// Initialize a new polls api - const PollsApi(this._client); - - final StreamHttpClient _client; - - /// Creates a new poll with the given [poll] data. - Future createPoll( - Poll poll, - ) async { - final response = await _client.post( - '/polls', - data: jsonEncode(poll), - ); - - return CreatePollResponse.fromJson(response.data); - } - - /// Retrieves a poll by its [id]. - Future getPoll( - String id, - ) async { - final response = await _client.get( - '/polls/$id', - ); - - return GetPollResponse.fromJson(response.data); - } - - /// Updates the poll with the given [poll] data. - Future updatePoll( - Poll poll, - ) async { - final response = await _client.put( - '/polls', - data: jsonEncode(poll), - ); - - return UpdatePollResponse.fromJson(response.data); - } - - /// Partially updates the poll with the given [id]. - /// - /// The [set] and [unset] parameters are used to update the poll. - Future partialUpdatePoll( - String id, { - Map? set, - List? unset, - }) async { - final response = await _client.patch( - '/polls/$id', - data: jsonEncode({ - if (set != null) 'set': set, - if (unset != null) 'unset': unset, - }), - ); - - return UpdatePollResponse.fromJson(response.data); - } - - /// Deletes the poll with the given [id]. - Future deletePoll(String id) async { - final response = await _client.delete( - '/polls/$id', - ); - - return EmptyResponse.fromJson(response.data); - } - - /// Creates a new poll option with the given [option] data. - Future createPollOption( - String pollId, - PollOption option, - ) async { - final response = await _client.post( - '/polls/$pollId/options', - data: jsonEncode(option), - ); - - return CreatePollOptionResponse.fromJson(response.data); - } - - /// Retrieves a poll option by its [optionId]. - Future getPollOption( - String pollId, - String optionId, - ) async { - final response = await _client.get( - '/polls/$pollId/options/$optionId', - ); - - return GetPollOptionResponse.fromJson(response.data); - } - - /// Updates the poll option with the given [option] data. - Future updatePollOption( - String pollId, - PollOption option, - ) async { - final response = await _client.put( - '/polls/$pollId/options', - data: jsonEncode(option), - ); - - return UpdatePollOptionResponse.fromJson(response.data); - } - - /// Removes the poll option with the given [optionId]. - Future deletePollOption( - String pollId, - String optionId, - ) async { - final response = await _client.delete( - '/polls/$pollId/options/$optionId', - ); - - return EmptyResponse.fromJson(response.data); - } - - /// Casts a vote on the poll with the given [pollId] and [vote] data. - Future castPollVote( - String messageId, - String pollId, - PollVote vote, - ) async { - final response = await _client.post( - '/messages/$messageId/polls/$pollId/vote', - data: jsonEncode({ - 'vote': vote, - }), - ); - - return CastPollVoteResponse.fromJson(response.data); - } - - /// Removes the vote with the given [voteId] from the poll with the - /// given [pollId]. - Future removePollVote( - String messageId, - String pollId, - String voteId, - ) async { - final response = await _client.delete( - '/messages/$messageId/polls/$pollId/vote/$voteId', - ); - - return RemovePollVoteResponse.fromJson(response.data); - } - - /// Queries polls with the given [filter], [sort], and [pagination] - /// parameters. - Future queryPolls({ - PollFilter? filter, - List? sort, - PaginationParams pagination = const PaginationParams(), - }) async { - final response = await _client.post( - '/polls/query', - data: jsonEncode({ - if (filter != null) 'filter': filter.toJson(), - if (sort != null) 'sort': sort.map((e) => e.toJson()).toList(), - ...pagination.toJson(), - }), - ); - - return QueryPollsResponse.fromJson(response.data); - } - - /// Queries poll votes with the given [pollId], [filter], [sort], and - Future queryPollVotes( - String pollId, { - PollVoteFilter? filter, - List? sort, - PaginationParams pagination = const PaginationParams(), - }) async { - final response = await _client.post( - '/polls/$pollId/votes', - data: jsonEncode({ - if (filter != null) 'filter': filter.toJson(), - if (sort != null) 'sort': sort.map((e) => e.toJson()).toList(), - ...pagination.toJson(), - }), - ); - - return QueryPollVotesResponse.fromJson(response.data); - } -} diff --git a/packages/stream_chat/lib/src/core/api/responses.dart b/packages/stream_chat/lib/src/core/api/responses.dart index f288c9cf9f..5e11c26205 100644 --- a/packages/stream_chat/lib/src/core/api/responses.dart +++ b/packages/stream_chat/lib/src/core/api/responses.dart @@ -9,9 +9,6 @@ import '../models/location.dart'; import '../models/member.dart'; import '../models/message.dart'; import '../models/message_reminder.dart'; -import '../models/poll.dart'; -import '../models/poll_option.dart'; -import '../models/poll_vote.dart'; import '../models/predefined_filter.dart'; import '../models/push_preference.dart'; import '../models/reaction.dart'; @@ -436,114 +433,6 @@ class BlockedUsersResponse extends _BaseResponse { static BlockedUsersResponse fromJson(Map json) => _$BlockedUsersResponseFromJson(json); } -/// Model response for [StreamChatClient.createPoll] api call -@JsonSerializable(createToJson: false) -class CreatePollResponse extends _BaseResponse { - /// Created poll - late Poll poll; - - /// Create a new instance from a json - static CreatePollResponse fromJson(Map json) => _$CreatePollResponseFromJson(json); -} - -/// Model response for [StreamChatClient.getPoll] api call -@JsonSerializable(createToJson: false) -class GetPollResponse extends _BaseResponse { - /// Fetched poll - late Poll poll; - - /// Create a new instance from a json - static GetPollResponse fromJson(Map json) => _$GetPollResponseFromJson(json); -} - -/// Model response for [StreamChatClient.updatePoll] api call -@JsonSerializable(createToJson: false) -class UpdatePollResponse extends _BaseResponse { - /// Updated poll - late Poll poll; - - /// Create a new instance from a json - static UpdatePollResponse fromJson(Map json) => _$UpdatePollResponseFromJson(json); -} - -/// Model response for [StreamChatClient.createPollOption] api call -@JsonSerializable(createToJson: false) -class CreatePollOptionResponse extends _BaseResponse { - /// Created poll option - late PollOption pollOption; - - /// Create a new instance from a json - static CreatePollOptionResponse fromJson(Map json) => _$CreatePollOptionResponseFromJson(json); -} - -/// Model response for [StreamChatClient.getPollOption] api call -@JsonSerializable(createToJson: false) -class GetPollOptionResponse extends _BaseResponse { - /// Fetched poll option - late PollOption pollOption; - - /// Create a new instance from a json - static GetPollOptionResponse fromJson(Map json) => _$GetPollOptionResponseFromJson(json); -} - -/// Model response for [StreamChatClient.updatePollOption] api call -@JsonSerializable(createToJson: false) -class UpdatePollOptionResponse extends _BaseResponse { - /// Updated poll option - late PollOption pollOption; - - /// Create a new instance from a json - static UpdatePollOptionResponse fromJson(Map json) => _$UpdatePollOptionResponseFromJson(json); -} - -/// Model response for [StreamChatClient.castPollVote] api call -@JsonSerializable(createToJson: false) -class CastPollVoteResponse extends _BaseResponse { - /// Casted vote - late PollVote vote; - - /// Create a new instance from a json - static CastPollVoteResponse fromJson(Map json) => _$CastPollVoteResponseFromJson(json); -} - -/// Model response for [StreamChatClient.removePollVote] api call -@JsonSerializable(createToJson: false) -class RemovePollVoteResponse extends EmptyResponse { - /// Deleted vote - late PollVote vote; - - /// Create a new instance from a json - static RemovePollVoteResponse fromJson(Map json) => _$RemovePollVoteResponseFromJson(json); -} - -/// Model response for [StreamChatClient.queryPolls] api call -@JsonSerializable(createToJson: false) -class QueryPollsResponse extends _BaseResponse { - /// List of polls returned by the query - @JsonKey(defaultValue: []) - late List polls; - - /// Poll id of where to start searching from for next [results] - late String? next; - - /// Create a new instance from a json - static QueryPollsResponse fromJson(Map json) => _$QueryPollsResponseFromJson(json); -} - -/// Model response for [StreamChatClient.queryPollVotes] api call -@JsonSerializable(createToJson: false) -class QueryPollVotesResponse extends _BaseResponse { - /// List of poll votes returned by the query - @JsonKey(defaultValue: []) - late List votes; - - /// Poll vote id of where to start searching from for next [results] - late String? next; - - /// Create a new instance from a json - static QueryPollVotesResponse fromJson(Map json) => _$QueryPollVotesResponseFromJson(json); -} - /// Model response for [StreamChatClient.getThread] api call @JsonSerializable(createToJson: false) class GetThreadResponse extends _BaseResponse { diff --git a/packages/stream_chat/lib/src/core/api/responses.g.dart b/packages/stream_chat/lib/src/core/api/responses.g.dart index f7d5e36ba1..1bf1e36e76 100644 --- a/packages/stream_chat/lib/src/core/api/responses.g.dart +++ b/packages/stream_chat/lib/src/core/api/responses.g.dart @@ -25,67 +25,48 @@ SyncResponse _$SyncResponseFromJson(Map json) => SyncResponse() ..duration = json['duration'] as String? ..events = (json['events'] as List?)?.map((e) => Event.fromJson(e as Map)).toList() ?? []; -QueryChannelsResponse _$QueryChannelsResponseFromJson( - Map json, -) => QueryChannelsResponse() +QueryChannelsResponse _$QueryChannelsResponseFromJson(Map json) => QueryChannelsResponse() ..duration = json['duration'] as String? ..channels = (json['channels'] as List?)?.map((e) => ChannelState.fromJson(e as Map)).toList() ?? [] ..predefinedFilter = json['predefined_filter'] == null ? null - : PredefinedFilter.fromJson( - json['predefined_filter'] as Map, - ); + : PredefinedFilter.fromJson(json['predefined_filter'] as Map); -TranslateMessageResponse _$TranslateMessageResponseFromJson( - Map json, -) => TranslateMessageResponse() +TranslateMessageResponse _$TranslateMessageResponseFromJson(Map json) => TranslateMessageResponse() ..duration = json['duration'] as String? ..message = Message.fromJson(json['message'] as Map); -QueryMembersResponse _$QueryMembersResponseFromJson( - Map json, -) => QueryMembersResponse() +QueryMembersResponse _$QueryMembersResponseFromJson(Map json) => QueryMembersResponse() ..duration = json['duration'] as String? ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? []; -PartialUpdateMemberResponse _$PartialUpdateMemberResponseFromJson( - Map json, -) => PartialUpdateMemberResponse() - ..duration = json['duration'] as String? - ..channelMember = Member.fromJson( - json['channel_member'] as Map, - ); +PartialUpdateMemberResponse _$PartialUpdateMemberResponseFromJson(Map json) => + PartialUpdateMemberResponse() + ..duration = json['duration'] as String? + ..channelMember = Member.fromJson(json['channel_member'] as Map); QueryUsersResponse _$QueryUsersResponseFromJson(Map json) => QueryUsersResponse() ..duration = json['duration'] as String? ..users = (json['users'] as List?)?.map((e) => User.fromJson(e as Map)).toList() ?? []; -QueryBannedUsersResponse _$QueryBannedUsersResponseFromJson( - Map json, -) => QueryBannedUsersResponse() +QueryBannedUsersResponse _$QueryBannedUsersResponseFromJson(Map json) => QueryBannedUsersResponse() ..duration = json['duration'] as String? ..bans = (json['bans'] as List?)?.map((e) => BannedUser.fromJson(e as Map)).toList() ?? []; -QueryReactionsResponse _$QueryReactionsResponseFromJson( - Map json, -) => QueryReactionsResponse() +QueryReactionsResponse _$QueryReactionsResponseFromJson(Map json) => QueryReactionsResponse() ..duration = json['duration'] as String? ..reactions = (json['reactions'] as List?)?.map((e) => Reaction.fromJson(e as Map)).toList() ?? [] ..next = json['next'] as String?; -QueryRepliesResponse _$QueryRepliesResponseFromJson( - Map json, -) => QueryRepliesResponse() +QueryRepliesResponse _$QueryRepliesResponseFromJson(Map json) => QueryRepliesResponse() ..duration = json['duration'] as String? ..messages = (json['messages'] as List?)?.map((e) => Message.fromJson(e as Map)).toList() ?? []; -SendReactionResponse _$SendReactionResponseFromJson( - Map json, -) => SendReactionResponse() +SendReactionResponse _$SendReactionResponseFromJson(Map json) => SendReactionResponse() ..duration = json['duration'] as String? ..message = Message.fromJson(json['message'] as Map) ..reaction = Reaction.fromJson(json['reaction'] as Map); @@ -93,14 +74,10 @@ SendReactionResponse _$SendReactionResponseFromJson( UpdateUsersResponse _$UpdateUsersResponseFromJson(Map json) => UpdateUsersResponse() ..duration = json['duration'] as String? ..users = - (json['users'] as Map?)?.map( - (k, e) => MapEntry(k, User.fromJson(e as Map)), - ) ?? + (json['users'] as Map?)?.map((k, e) => MapEntry(k, User.fromJson(e as Map))) ?? {}; -UpdateMessageResponse _$UpdateMessageResponseFromJson( - Map json, -) => UpdateMessageResponse() +UpdateMessageResponse _$UpdateMessageResponseFromJson(Map json) => UpdateMessageResponse() ..duration = json['duration'] as String? ..message = Message.fromJson(json['message'] as Map); @@ -113,9 +90,7 @@ GetMessageResponse _$GetMessageResponseFromJson(Map json) => Ge ..message = Message.fromJson(json['message'] as Map) ..channel = json['channel'] == null ? null : ChannelModel.fromJson(json['channel'] as Map); -SearchMessagesResponse _$SearchMessagesResponseFromJson( - Map json, -) => SearchMessagesResponse() +SearchMessagesResponse _$SearchMessagesResponseFromJson(Map json) => SearchMessagesResponse() ..duration = json['duration'] as String? ..results = (json['results'] as List?) @@ -125,39 +100,30 @@ SearchMessagesResponse _$SearchMessagesResponseFromJson( ..next = json['next'] as String? ..previous = json['previous'] as String?; -GetMessagesByIdResponse _$GetMessagesByIdResponseFromJson( - Map json, -) => GetMessagesByIdResponse() +GetMessagesByIdResponse _$GetMessagesByIdResponseFromJson(Map json) => GetMessagesByIdResponse() ..duration = json['duration'] as String? ..messages = (json['messages'] as List?)?.map((e) => Message.fromJson(e as Map)).toList() ?? []; -UpdateChannelResponse _$UpdateChannelResponseFromJson( - Map json, -) => UpdateChannelResponse() +UpdateChannelResponse _$UpdateChannelResponseFromJson(Map json) => UpdateChannelResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); -PartialUpdateChannelResponse _$PartialUpdateChannelResponseFromJson( - Map json, -) => PartialUpdateChannelResponse() - ..duration = json['duration'] as String? - ..channel = ChannelModel.fromJson(json['channel'] as Map) - ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList(); +PartialUpdateChannelResponse _$PartialUpdateChannelResponseFromJson(Map json) => + PartialUpdateChannelResponse() + ..duration = json['duration'] as String? + ..channel = ChannelModel.fromJson(json['channel'] as Map) + ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList(); -InviteMembersResponse _$InviteMembersResponseFromJson( - Map json, -) => InviteMembersResponse() +InviteMembersResponse _$InviteMembersResponseFromJson(Map json) => InviteMembersResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); -RemoveMembersResponse _$RemoveMembersResponseFromJson( - Map json, -) => RemoveMembersResponse() +RemoveMembersResponse _$RemoveMembersResponseFromJson(Map json) => RemoveMembersResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] @@ -173,17 +139,13 @@ AddMembersResponse _$AddMembersResponseFromJson(Map json) => Ad ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); -AcceptInviteResponse _$AcceptInviteResponseFromJson( - Map json, -) => AcceptInviteResponse() +AcceptInviteResponse _$AcceptInviteResponseFromJson(Map json) => AcceptInviteResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] ..message = json['message'] == null ? null : Message.fromJson(json['message'] as Map); -RejectInviteResponse _$RejectInviteResponseFromJson( - Map json, -) => RejectInviteResponse() +RejectInviteResponse _$RejectInviteResponseFromJson(Map json) => RejectInviteResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) ..members = (json['members'] as List?)?.map((e) => Member.fromJson(e as Map)).toList() ?? [] @@ -192,9 +154,7 @@ RejectInviteResponse _$RejectInviteResponseFromJson( EmptyResponse _$EmptyResponseFromJson(Map json) => EmptyResponse()..duration = json['duration'] as String?; -ChannelStateResponse _$ChannelStateResponseFromJson( - Map json, -) => ChannelStateResponse() +ChannelStateResponse _$ChannelStateResponseFromJson(Map json) => ChannelStateResponse() ..duration = json['duration'] as String? ..channel = ChannelModel.fromJson(json['channel'] as Map) ..messages = @@ -209,86 +169,20 @@ UserBlockResponse _$UserBlockResponseFromJson(Map json) => User ..blockedUserId = json['blocked_user_id'] as String? ?? '' ..createdAt = DateTime.parse(json['created_at'] as String); -BlockedUsersResponse _$BlockedUsersResponseFromJson( - Map json, -) => BlockedUsersResponse() +BlockedUsersResponse _$BlockedUsersResponseFromJson(Map json) => BlockedUsersResponse() ..duration = json['duration'] as String? ..blocks = (json['blocks'] as List?)?.map((e) => UserBlock.fromJson(e as Map)).toList() ?? []; -CreatePollResponse _$CreatePollResponseFromJson(Map json) => CreatePollResponse() - ..duration = json['duration'] as String? - ..poll = Poll.fromJson(json['poll'] as Map); - -GetPollResponse _$GetPollResponseFromJson(Map json) => GetPollResponse() - ..duration = json['duration'] as String? - ..poll = Poll.fromJson(json['poll'] as Map); - -UpdatePollResponse _$UpdatePollResponseFromJson(Map json) => UpdatePollResponse() - ..duration = json['duration'] as String? - ..poll = Poll.fromJson(json['poll'] as Map); - -CreatePollOptionResponse _$CreatePollOptionResponseFromJson( - Map json, -) => CreatePollOptionResponse() - ..duration = json['duration'] as String? - ..pollOption = PollOption.fromJson( - json['poll_option'] as Map, - ); - -GetPollOptionResponse _$GetPollOptionResponseFromJson( - Map json, -) => GetPollOptionResponse() - ..duration = json['duration'] as String? - ..pollOption = PollOption.fromJson( - json['poll_option'] as Map, - ); - -UpdatePollOptionResponse _$UpdatePollOptionResponseFromJson( - Map json, -) => UpdatePollOptionResponse() - ..duration = json['duration'] as String? - ..pollOption = PollOption.fromJson( - json['poll_option'] as Map, - ); - -CastPollVoteResponse _$CastPollVoteResponseFromJson( - Map json, -) => CastPollVoteResponse() - ..duration = json['duration'] as String? - ..vote = PollVote.fromJson(json['vote'] as Map); - -RemovePollVoteResponse _$RemovePollVoteResponseFromJson( - Map json, -) => RemovePollVoteResponse() - ..duration = json['duration'] as String? - ..vote = PollVote.fromJson(json['vote'] as Map); - -QueryPollsResponse _$QueryPollsResponseFromJson(Map json) => QueryPollsResponse() - ..duration = json['duration'] as String? - ..polls = (json['polls'] as List?)?.map((e) => Poll.fromJson(e as Map)).toList() ?? [] - ..next = json['next'] as String?; - -QueryPollVotesResponse _$QueryPollVotesResponseFromJson( - Map json, -) => QueryPollVotesResponse() - ..duration = json['duration'] as String? - ..votes = (json['votes'] as List?)?.map((e) => PollVote.fromJson(e as Map)).toList() ?? [] - ..next = json['next'] as String?; - GetThreadResponse _$GetThreadResponseFromJson(Map json) => GetThreadResponse() ..duration = json['duration'] as String? ..thread = Thread.fromJson(json['thread'] as Map); -UpdateThreadResponse _$UpdateThreadResponseFromJson( - Map json, -) => UpdateThreadResponse() +UpdateThreadResponse _$UpdateThreadResponseFromJson(Map json) => UpdateThreadResponse() ..duration = json['duration'] as String? ..thread = Thread.fromJson(json['thread'] as Map); -QueryThreadsResponse _$QueryThreadsResponseFromJson( - Map json, -) => QueryThreadsResponse() +QueryThreadsResponse _$QueryThreadsResponseFromJson(Map json) => QueryThreadsResponse() ..duration = json['duration'] as String? ..threads = (json['threads'] as List?)?.map((e) => Thread.fromJson(e as Map)).toList() ?? [] ..next = json['next'] as String?; @@ -306,34 +200,22 @@ QueryDraftsResponse _$QueryDraftsResponseFromJson(Map json) => ..drafts = (json['drafts'] as List?)?.map((e) => Draft.fromJson(e as Map)).toList() ?? [] ..next = json['next'] as String?; -CreateReminderResponse _$CreateReminderResponseFromJson( - Map json, -) => CreateReminderResponse() +CreateReminderResponse _$CreateReminderResponseFromJson(Map json) => CreateReminderResponse() ..duration = json['duration'] as String? - ..reminder = MessageReminder.fromJson( - json['reminder'] as Map, - ); + ..reminder = MessageReminder.fromJson(json['reminder'] as Map); -UpdateReminderResponse _$UpdateReminderResponseFromJson( - Map json, -) => UpdateReminderResponse() +UpdateReminderResponse _$UpdateReminderResponseFromJson(Map json) => UpdateReminderResponse() ..duration = json['duration'] as String? - ..reminder = MessageReminder.fromJson( - json['reminder'] as Map, - ); + ..reminder = MessageReminder.fromJson(json['reminder'] as Map); -QueryRemindersResponse _$QueryRemindersResponseFromJson( - Map json, -) => QueryRemindersResponse() +QueryRemindersResponse _$QueryRemindersResponseFromJson(Map json) => QueryRemindersResponse() ..duration = json['duration'] as String? ..reminders = (json['reminders'] as List?)?.map((e) => MessageReminder.fromJson(e as Map)).toList() ?? [] ..next = json['next'] as String?; -GetUnreadCountResponse _$GetUnreadCountResponseFromJson( - Map json, -) => GetUnreadCountResponse() +GetUnreadCountResponse _$GetUnreadCountResponseFromJson(Map json) => GetUnreadCountResponse() ..duration = json['duration'] as String? ..totalUnreadCount = (json['total_unread_count'] as num).toInt() ..totalUnreadThreadsCount = (json['total_unread_threads_count'] as num).toInt() @@ -350,31 +232,24 @@ GetUnreadCountResponse _$GetUnreadCountResponseFromJson( .map((e) => UnreadCountsThread.fromJson(e as Map)) .toList(); -UpsertPushPreferencesResponse _$UpsertPushPreferencesResponseFromJson( - Map json, -) => UpsertPushPreferencesResponse() - ..duration = json['duration'] as String? - ..userPreferences = _userPreferencesFromJson( - json['user_preferences'] as Map?, - ) - ..userChannelPreferences = - (json['user_channel_preferences'] as Map?)?.map( - (k, e) => MapEntry( - k, - (e as Map).map( +UpsertPushPreferencesResponse _$UpsertPushPreferencesResponseFromJson(Map json) => + UpsertPushPreferencesResponse() + ..duration = json['duration'] as String? + ..userPreferences = _userPreferencesFromJson(json['user_preferences'] as Map?) + ..userChannelPreferences = + (json['user_channel_preferences'] as Map?)?.map( (k, e) => MapEntry( k, - ChannelPushPreference.fromJson(e as Map), + (e as Map).map( + (k, e) => MapEntry(k, ChannelPushPreference.fromJson(e as Map)), + ), ), - ), - ), - ) ?? - {}; - -GetActiveLiveLocationsResponse _$GetActiveLiveLocationsResponseFromJson( - Map json, -) => GetActiveLiveLocationsResponse() - ..duration = json['duration'] as String? - ..activeLiveLocations = (json['active_live_locations'] as List) - .map((e) => Location.fromJson(e as Map)) - .toList(); + ) ?? + {}; + +GetActiveLiveLocationsResponse _$GetActiveLiveLocationsResponseFromJson(Map json) => + GetActiveLiveLocationsResponse() + ..duration = json['duration'] as String? + ..activeLiveLocations = (json['active_live_locations'] as List) + .map((e) => Location.fromJson(e as Map)) + .toList(); diff --git a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart index 1631e9c8cb..3726a0eba0 100644 --- a/packages/stream_chat/lib/src/core/api/stream_chat_api.dart +++ b/packages/stream_chat/lib/src/core/api/stream_chat_api.dart @@ -6,7 +6,6 @@ import 'channel_api.dart'; import 'general_api.dart'; import 'message_api.dart'; import 'moderation_api.dart'; -import 'polls_api.dart'; import 'push_preferences_api.dart'; import 'reminders_api.dart'; import 'threads_api.dart'; @@ -46,10 +45,6 @@ class StreamChatApi { MessageApi get message => _message ??= MessageApi(_client); MessageApi? _message; - /// Api dedicated to polls operations - PollsApi get polls => _polls ??= PollsApi(_client); - PollsApi? _polls; - /// Api dedicated to threads operations ThreadsApi get threads => _threads ??= ThreadsApi(_client); ThreadsApi? _threads; diff --git a/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart b/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart index ea45f042f5..5fce137bd7 100644 --- a/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart +++ b/packages/stream_chat/lib/src/core/models/converters/v1_json_converters.dart @@ -2,10 +2,16 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:meta/meta.dart'; import 'package:stream_core/stream_core.dart' show StreamDateTimeConverter; +import '../../util/serializer.dart'; import '../device.dart'; +import '../poll.dart'; +import '../poll_option.dart'; +import '../poll_vote.dart'; import '../push_provider.dart'; +import '../user.dart'; import '../user_group.dart'; import '../user_group_member.dart'; +import '../voting_visibility.dart'; // Converters for plain models embedded in json_serializable models that still decode v1 JSON. Each one is deleted // by the migration group that turns its parent into a plain model. @@ -65,3 +71,173 @@ UserGroupMember _userGroupMemberFromV1Json(Map json) => UserGro isAdmin: json['is_admin'] as bool, userId: json['user_id'] as String, ); + +/// Converts a [Poll] to and from its v1 keys. +/// +/// Custom data sits at the root of the v1 map, beside the known keys. +// TODO(openapi-migration): remove once events and messages decode v2 payloads +@internal +class PollV1JsonConverter implements JsonConverter> { + /// Creates a new [PollV1JsonConverter]. + const PollV1JsonConverter(); + + @override + Poll fromJson(Map json) => Poll( + id: json['id'] as String?, + name: json['name'] as String, + nameI18n: _translations(json['name_i18n']), + description: json['description'] as String?, + descriptionI18n: _translations(json['description_i18n']), + options: (json['options'] as List) + .map((option) => _pollOptionFromV1Json(option as Map)) + .toList(), + votingVisibility: switch (json['voting_visibility']) { + final String value => VotingVisibility(value), + _ => VotingVisibility.public, + }, + enforceUniqueVote: json['enforce_unique_vote'] as bool? ?? true, + maxVotesAllowed: (json['max_votes_allowed'] as num?)?.toInt(), + allowAnswers: json['allow_answers'] as bool? ?? false, + latestAnswers: _pollVotesFromV1Json(json['latest_answers']) ?? const [], + answersCount: (json['answers_count'] as num?)?.toInt() ?? 0, + allowUserSuggestedOptions: json['allow_user_suggested_options'] as bool? ?? false, + isClosed: json['is_closed'] as bool? ?? false, + createdAt: _optionalDateTime(json['created_at']), + updatedAt: _optionalDateTime(json['updated_at']), + voteCountsByOption: + (json['vote_counts_by_option'] as Map?)?.map( + (optionId, count) => MapEntry(optionId, (count as num).toInt()), + ) ?? + const {}, + voteCount: (json['vote_count'] as num?)?.toInt() ?? 0, + latestVotesByOption: + (json['latest_votes_by_option'] as Map?)?.map( + (optionId, votes) => MapEntry(optionId, _pollVotesFromV1Json(votes)!), + ) ?? + const {}, + createdById: json['created_by_id'] as String?, + createdBy: switch (json['created_by']) { + final Map user => User.fromJson(user), + _ => null, + }, + ownVotesAndAnswers: _pollVotesFromV1Json(json['own_votes']) ?? const [], + custom: _customData(json, _pollV1Keys), + ); + + // Writes the keys the poll settings use, with custom data at the root; the + // vote summary, the translations and a custom entry named like one of the + // keys are not written. + @override + Map toJson(Poll poll) => { + ..._customDataAtRoot(poll.custom, _pollV1Keys), + 'id': poll.id, + 'name': poll.name, + 'description': poll.description, + 'options': poll.options.map(_pollOptionToV1Json).toList(), + 'voting_visibility': poll.votingVisibility, + 'enforce_unique_vote': poll.enforceUniqueVote, + 'max_votes_allowed': poll.maxVotesAllowed, + 'allow_user_suggested_options': poll.allowUserSuggestedOptions, + 'allow_answers': poll.allowAnswers, + 'is_closed': poll.isClosed, + }; +} + +/// Converts a [PollVote] to and from its v1 keys. +// TODO(openapi-migration): remove once WebSocket events decode v2 payloads +@internal +class PollVoteV1JsonConverter implements JsonConverter> { + /// Creates a new [PollVoteV1JsonConverter]. + const PollVoteV1JsonConverter(); + + @override + PollVote fromJson(Map json) => _pollVoteFromV1Json(json); + + // Writes only the keys that identify the vote and what it selects. + @override + Map toJson(PollVote vote) => { + 'id': ?vote.id, + 'option_id': ?vote.optionId, + 'answer_text': ?vote.answerText, + }; +} + +PollOption _pollOptionFromV1Json(Map json) => PollOption( + id: json['id'] as String?, + text: json['text'] as String, + textI18n: _translations(json['text_i18n']), + custom: _customData(json, _pollOptionV1Keys), +); + +Map _pollOptionToV1Json(PollOption option) => { + ..._customDataAtRoot(option.custom, _pollOptionV1Keys), + 'id': ?option.id, + 'text': option.text, +}; + +List? _pollVotesFromV1Json(Object? json) => + (json as List?)?.map((vote) => _pollVoteFromV1Json(vote as Map)).toList(); + +PollVote _pollVoteFromV1Json(Map json) => PollVote( + id: json['id'] as String?, + pollId: json['poll_id'] as String?, + optionId: json['option_id'] as String?, + answerText: json['answer_text'] as String?, + answerTextI18n: _translations(json['answer_text_i18n']), + createdAt: _optionalDateTime(json['created_at']), + updatedAt: _optionalDateTime(json['updated_at']), + userId: json['user_id'] as String?, + user: switch (json['user']) { + final Map user => User.fromJson(user), + _ => null, + }, +); + +Map? _translations(Object? json) => switch (json) { + final Map translations => Map.from(translations), + _ => null, +}; + +DateTime? _optionalDateTime(Object? json) => json == null ? null : _dateTime.fromJson(json); + +// The keys a v1 poll carries besides its custom data. +const _pollV1Keys = [ + 'id', + 'name', + 'name_i18n', + 'description', + 'description_i18n', + 'options', + 'voting_visibility', + 'enforce_unique_vote', + 'max_votes_allowed', + 'allow_user_suggested_options', + 'allow_answers', + 'is_closed', + 'created_at', + 'updated_at', + 'vote_counts_by_option', + 'votes', + 'own_votes', + 'vote_count', + 'answers_count', + 'latest_votes_by_option', + 'latest_answers', + 'created_by_id', + 'created_by', +]; + +// The keys a v1 poll option carries besides its custom data. +const _pollOptionV1Keys = [ + 'id', + 'text', + 'text_i18n', +]; + +Map _customData(Map json, List knownKeys) => + Serializer.moveToExtraDataFromRoot(json, knownKeys)['extra_data'] as Map; + +// The custom data a v1 map carries at its root: an entry named like one of the +// known keys would overwrite the key, so it is left out. +Map _customDataAtRoot(Map custom, List knownKeys) => + {...custom}..removeWhere((key, _) => knownKeys.contains(key)); diff --git a/packages/stream_chat/lib/src/core/models/draft_message.dart b/packages/stream_chat/lib/src/core/models/draft_message.dart index 000bb9f168..01f370abcc 100644 --- a/packages/stream_chat/lib/src/core/models/draft_message.dart +++ b/packages/stream_chat/lib/src/core/models/draft_message.dart @@ -4,6 +4,7 @@ import 'package:uuid/uuid.dart'; import '../util/serializer.dart'; import 'attachment.dart'; +import 'converters/v1_json_converters.dart'; import 'message.dart'; import 'poll.dart'; import 'user.dart'; @@ -84,6 +85,7 @@ class DraftMessage extends Equatable { /// The poll associated with the message. @JsonKey(includeToJson: false) + @PollV1JsonConverter() final Poll? poll; /// The ID of the poll, if a poll is associated with the message. diff --git a/packages/stream_chat/lib/src/core/models/draft_message.g.dart b/packages/stream_chat/lib/src/core/models/draft_message.g.dart index 511ec1822e..27e1e0a332 100644 --- a/packages/stream_chat/lib/src/core/models/draft_message.g.dart +++ b/packages/stream_chat/lib/src/core/models/draft_message.g.dart @@ -24,7 +24,7 @@ DraftMessage _$DraftMessageFromJson(Map json) => DraftMessage( quotedMessageId: json['quoted_message_id'] as String?, silent: json['silent'] as bool? ?? false, command: json['command'] as String?, - poll: json['poll'] == null ? null : Poll.fromJson(json['poll'] as Map), + poll: _$JsonConverterFromJson, Poll>(json['poll'], const PollV1JsonConverter().fromJson), pollId: json['poll_id'] as String?, extraData: json['extra_data'] as Map? ?? const {}, ); @@ -42,3 +42,6 @@ Map _$DraftMessageToJson(DraftMessage instance) => (Object? json, Value? Function(Json json) fromJson) => + json == null ? null : fromJson(json as Json); diff --git a/packages/stream_chat/lib/src/core/models/message.dart b/packages/stream_chat/lib/src/core/models/message.dart index 8ca0232d50..974e8bf704 100644 --- a/packages/stream_chat/lib/src/core/models/message.dart +++ b/packages/stream_chat/lib/src/core/models/message.dart @@ -301,6 +301,7 @@ class Message extends Equatable { /// The poll associated with this message. @JsonKey(includeToJson: false) + @PollV1JsonConverter() final Poll? poll; /// The ID of the [poll] associated with this message. diff --git a/packages/stream_chat/lib/src/core/models/message.g.dart b/packages/stream_chat/lib/src/core/models/message.g.dart index b5d5d2b59d..ad14f0dda3 100644 --- a/packages/stream_chat/lib/src/core/models/message.g.dart +++ b/packages/stream_chat/lib/src/core/models/message.g.dart @@ -55,18 +55,14 @@ Message _$MessageFromJson(Map json) => Message( pinnedAt: json['pinned_at'] == null ? null : DateTime.parse(json['pinned_at'] as String), pinExpires: json['pin_expires'] == null ? null : DateTime.parse(json['pin_expires'] as String), pinnedBy: json['pinned_by'] == null ? null : User.fromJson(json['pinned_by'] as Map), - poll: json['poll'] == null ? null : Poll.fromJson(json['poll'] as Map), + poll: _$JsonConverterFromJson, Poll>(json['poll'], const PollV1JsonConverter().fromJson), pollId: json['poll_id'] as String?, extraData: json['extra_data'] as Map? ?? const {}, - i18n: (json['i18n'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ), + i18n: (json['i18n'] as Map?)?.map((k, e) => MapEntry(k, e as String)), restrictedVisibility: (json['restricted_visibility'] as List?)?.map((e) => e as String).toList(), moderation: Message._moderationReadValue(json, 'moderation') == null ? null - : Moderation.fromJson( - Message._moderationReadValue(json, 'moderation') as Map, - ), + : Moderation.fromJson(Message._moderationReadValue(json, 'moderation') as Map), draft: json['draft'] == null ? null : Draft.fromJson(json['draft'] as Map), reminder: json['reminder'] == null ? null : MessageReminder.fromJson(json['reminder'] as Map), channelRole: Message._channelRoleReadValue(json, 'channel_role') as String?, @@ -96,3 +92,6 @@ Map _$MessageToJson(Message instance) => { 'shared_location': ?instance.sharedLocation?.toJson(), 'extra_data': instance.extraData, }; + +Value? _$JsonConverterFromJson(Object? json, Value? Function(Json json) fromJson) => + json == null ? null : fromJson(json as Json); diff --git a/packages/stream_chat/lib/src/core/models/poll.dart b/packages/stream_chat/lib/src/core/models/poll.dart index de883dbf8f..bc9105107c 100644 --- a/packages/stream_chat/lib/src/core/models/poll.dart +++ b/packages/stream_chat/lib/src/core/models/poll.dart @@ -1,43 +1,27 @@ import 'package:collection/collection.dart'; -import 'package:equatable/equatable.dart'; -import 'package:json_annotation/json_annotation.dart'; -import 'package:meta/meta.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:stream_core/stream_core.dart' show Filter, FilterField, Standard, Sort, SortField, normalizeStringForSort; import 'package:uuid/uuid.dart'; -import '../util/serializer.dart'; import 'poll_option.dart'; import 'poll_vote.dart'; import 'user.dart'; +import 'voting_visibility.dart'; -part 'poll.g.dart'; +part 'poll.freezed.dart'; -class _NullConst { - const _NullConst(); -} - -const _nullConst = _NullConst(); - -/// {@template streamVotingVisibility} -/// Represents the visibility of the voting process. -/// {@endtemplate} -enum VotingVisibility { - /// The voting process is anonymous. - @JsonValue('anonymous') - anonymous, - - /// The voting process is public. - @JsonValue('public') - public, -} - -/// {@template streamPoll} -/// A model class representing a poll. -/// {@endtemplate} -@JsonSerializable() -class Poll extends Equatable { - /// {@macro streamPoll} +/// A question with a set of options that the members of a channel vote on. +/// +/// A poll is sent in a message. Besides its settings it carries a summary of +/// the votes so far: the counts per option, the latest votes and answers, and +/// the votes of the current user. +@freezed +class Poll with _$Poll { + /// Creates a new [Poll]. + /// + /// An [id] is generated when omitted, and [createdAt] and [updatedAt] + /// default to the current time. Poll({ String? id, required this.name, @@ -61,19 +45,17 @@ class Poll extends Equatable { this.createdById, this.createdBy, this.ownVotesAndAnswers = const [], - this.extraData = const {}, + this.custom = const {}, }) : id = id ?? const Uuid().v4(), createdAt = createdAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(); - /// Create a new instance from a json - factory Poll.fromJson(Map json) => - _$PollFromJson(Serializer.moveToExtraDataFromRoot(json, topLevelFields)); - - /// The unique identifier of the poll. + /// The unique identifier of this poll. + @override final String id; - /// The name of the poll. + /// The question this poll asks. + @override final String name; /// The translations of [name], keyed as `_text`, plus the @@ -81,161 +63,119 @@ class Poll extends Equatable { /// /// Filled in by the server when the poll is sent to a channel with /// automatic translation enabled. - @JsonKey(includeToJson: false) + @override final Map? nameI18n; - /// The description of the poll. + /// A longer explanation of the question. + @override final String? description; /// The translations of [description], in the same shape as [nameI18n]. - @JsonKey(includeToJson: false) + @override final Map? descriptionI18n; - /// The list of options available for the poll. + /// The options that can be voted for. + @override final List options; - /// Represents the visibility of the voting process. + /// The rule for who can see which option each user voted for. /// /// Defaults to [VotingVisibility.public]. + @override final VotingVisibility votingVisibility; - /// If true, only unique votes are allowed. + /// Whether each user may vote for one option only. /// - /// Defaults to false. + /// Voting for another option replaces the previous vote. Defaults to true. + @override final bool enforceUniqueVote; - /// The maximum number of votes allowed per user. + /// The maximum number of options each user may vote for, or null for no + /// limit. + /// + /// Applies only when [enforceUniqueVote] is false. + @override final int? maxVotesAllowed; - /// If true, users can suggest their own options. + /// Whether users may add their own options to this poll. /// /// Defaults to false. + @override final bool allowUserSuggestedOptions; - /// If true, users can provide their own answers/comments. + /// Whether users may add a free-form answer. /// /// Defaults to false. + @override final bool allowAnswers; - /// Indicates if the poll is closed. + /// Whether this poll no longer accepts votes or answers. + @override final bool isClosed; - /// The total number of answers received by the poll. - @JsonKey(includeToJson: false) + /// The total number of answers added to this poll. + @override final int answersCount; - /// Map of vote counts by option. - @JsonKey(includeToJson: false) + /// The number of votes each option received, keyed by option id. + @override final Map voteCountsByOption; - /// Map of latest votes by option. - @JsonKey(includeToJson: false) + /// The most recent votes for each option, keyed by option id. + /// + /// Empty for an anonymous poll. + @override final Map> latestVotesByOption; - /// List of votes received by the poll. + /// The most recent votes across all options. /// - /// Note: This does not include the answers provided by the users, - /// see [latestAnswers] for that. - late final latestVotes = [...latestVotesByOption.values.flattened]; + /// Answers are not included; see [latestAnswers] for those. + @override + late final List latestVotes = [...latestVotesByOption.values.flattened]; - /// List of latest answers received by the poll. - @JsonKey(includeToJson: false) + /// The most recent answers added to this poll. + @override final List latestAnswers; - /// List of votes casted by the current user. - /// - /// Contains both votes and answers. - @JsonKey(name: 'own_votes', includeToJson: false) + /// The votes and answers of the current user. + @override final List ownVotesAndAnswers; - /// The total number of votes received by the poll. - @JsonKey(includeToJson: false) + /// The total number of votes cast on this poll. + @override final int voteCount; - /// List of votes casted by the current user. + /// The votes of the current user. /// - /// Note: This does not include the answers provided by the user, - /// see [ownAnswers] for that. - late final ownVotes = [...ownVotesAndAnswers.where((it) => !it.isAnswer)]; + /// Answers are not included; see [ownAnswers] for those. + @override + late final List ownVotes = [...ownVotesAndAnswers.where((it) => !it.isAnswer)]; - /// List of answers provided by the current user. + /// The answers of the current user. /// - /// Note: This does not include the votes casted by the user, - /// see [ownVotes] for that. - late final ownAnswers = [...ownVotesAndAnswers.where((it) => it.isAnswer)]; + /// Votes are not included; see [ownVotes] for those. + @override + late final List ownAnswers = [...ownVotesAndAnswers.where((it) => it.isAnswer)]; - /// The id of the user who created the poll. - @JsonKey(includeToJson: false) + /// The unique identifier of the user who created this poll. + @override final String? createdById; - /// The user who created the poll. - @JsonKey(includeToJson: false) + /// The user who created this poll. + @override final User? createdBy; - /// The date when the poll was created. - @JsonKey(includeToJson: false) + /// The date this poll was created. + @override final DateTime createdAt; - /// The date when the poll was last updated. - @JsonKey(includeToJson: false) + /// The date this poll was last changed. + @override final DateTime updatedAt; - /// Map of custom poll extraData - final Map extraData; - - /// Serialize to json - Map toJson() => Serializer.moveFromExtraDataToRoot(_$PollToJson(this)); - - /// Creates a copy of [Poll] with specified attributes overridden. - Poll copyWith({ - String? id, - String? name, - Map? nameI18n, - String? description, - Map? descriptionI18n, - List? options, - VotingVisibility? votingVisibility, - bool? enforceUniqueVote, - Object? maxVotesAllowed = _nullConst, - bool? allowUserSuggestedOptions, - bool? allowAnswers, - bool? isClosed, - Map? voteCountsByOption, - List? ownVotesAndAnswers, - int? voteCount, - int? answersCount, - Map>? latestVotesByOption, - List? latestAnswers, - String? createdById, - User? createdBy, - DateTime? createdAt, - DateTime? updatedAt, - Map? extraData, - }) => Poll( - id: id ?? this.id, - name: name ?? this.name, - nameI18n: nameI18n ?? this.nameI18n, - description: description ?? this.description, - descriptionI18n: descriptionI18n ?? this.descriptionI18n, - options: options ?? this.options, - votingVisibility: votingVisibility ?? this.votingVisibility, - enforceUniqueVote: enforceUniqueVote ?? this.enforceUniqueVote, - maxVotesAllowed: maxVotesAllowed == _nullConst ? this.maxVotesAllowed : maxVotesAllowed as int?, - allowUserSuggestedOptions: allowUserSuggestedOptions ?? this.allowUserSuggestedOptions, - allowAnswers: allowAnswers ?? this.allowAnswers, - isClosed: isClosed ?? this.isClosed, - voteCountsByOption: voteCountsByOption ?? this.voteCountsByOption, - ownVotesAndAnswers: ownVotesAndAnswers ?? this.ownVotesAndAnswers, - voteCount: voteCount ?? this.voteCount, - answersCount: answersCount ?? this.answersCount, - latestVotesByOption: latestVotesByOption ?? this.latestVotesByOption, - latestAnswers: latestAnswers ?? this.latestAnswers, - createdById: createdById ?? this.createdById, - createdBy: createdBy ?? this.createdBy, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - extraData: extraData ?? this.extraData, - ); + /// Custom data attached to this poll. + @override + final Map custom; /// This poll with the translations of [oldPoll] filled in where this one /// has none. @@ -263,61 +203,6 @@ class Poll extends Equatable { ], ); } - - /// Known top level fields. - /// - /// Useful for [Serializer] methods. - static const topLevelFields = [ - 'id', - 'name', - 'name_i18n', - 'description', - 'description_i18n', - 'options', - 'voting_visibility', - 'enforce_unique_vote', - 'max_votes_allowed', - 'allow_user_suggested_options', - 'allow_answers', - 'is_closed', - 'created_at', - 'updated_at', - 'vote_counts_by_option', - 'votes', - 'own_votes', - 'vote_count', - 'answers_count', - 'latest_votes_by_option', - 'latest_answers', - 'created_by_id', - 'created_by', - ]; - - @override - List get props => [ - id, - name, - nameI18n, - description, - descriptionI18n, - options, - votingVisibility, - enforceUniqueVote, - maxVotesAllowed, - allowUserSuggestedOptions, - allowAnswers, - isClosed, - voteCountsByOption, - ownVotesAndAnswers, - voteCount, - answersCount, - latestVotesByOption, - latestAnswers, - createdById, - createdBy, - createdAt, - updatedAt, - ]; } /// A filter for a poll query. @@ -335,12 +220,12 @@ class PollFilterField extends FilterField { /// off an instance with [value]. PollFilterField(super.remote, super.value); - /// Creates a field the SDK does not model, read from [Poll.extraData]. + /// Creates a field the SDK does not model, read from [Poll.custom]. /// /// **Supported operators:** `$eq`, `$in`, `$gt`, `$gte`, `$lt`, `$lte`, /// `$exists`, `$contains`, `$q`, `$autocomplete` factory PollFilterField.custom(String remote) { - return PollFilterField(remote, (it) => it.extraData[remote]); + return PollFilterField(remote, (it) => it.custom[remote]); } /// Filters polls by their id. @@ -404,7 +289,7 @@ class PollFilterField extends FilterField { /// **Supported operators:** `$eq` static final votingVisibility = PollFilterField( 'voting_visibility', - (it) => _$VotingVisibilityEnumMap[it.votingVisibility], + (it) => it.votingVisibility, ); /// Filters polls by their creation date. diff --git a/packages/stream_chat/lib/src/core/models/poll.freezed.dart b/packages/stream_chat/lib/src/core/models/poll.freezed.dart new file mode 100644 index 0000000000..d805de3284 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/poll.freezed.dart @@ -0,0 +1,308 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'poll.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$Poll { + String get id; + String get name; + Map? get nameI18n; + String? get description; + Map? get descriptionI18n; + List get options; + VotingVisibility get votingVisibility; + bool get enforceUniqueVote; + int? get maxVotesAllowed; + bool get allowUserSuggestedOptions; + bool get allowAnswers; + bool get isClosed; + int get answersCount; + Map get voteCountsByOption; + Map> get latestVotesByOption; + List get latestVotes; + List get latestAnswers; + List get ownVotesAndAnswers; + int get voteCount; + List get ownVotes; + List get ownAnswers; + String? get createdById; + User? get createdBy; + DateTime get createdAt; + DateTime get updatedAt; + Map get custom; + + /// Create a copy of Poll + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollCopyWith get copyWith => _$PollCopyWithImpl(this as Poll, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is Poll && + (identical(other.id, id) || other.id == id) && + (identical(other.name, name) || other.name == name) && + const DeepCollectionEquality().equals(other.nameI18n, nameI18n) && + (identical(other.description, description) || other.description == description) && + const DeepCollectionEquality().equals( + other.descriptionI18n, + descriptionI18n, + ) && + const DeepCollectionEquality().equals(other.options, options) && + (identical(other.votingVisibility, votingVisibility) || other.votingVisibility == votingVisibility) && + (identical(other.enforceUniqueVote, enforceUniqueVote) || other.enforceUniqueVote == enforceUniqueVote) && + (identical(other.maxVotesAllowed, maxVotesAllowed) || other.maxVotesAllowed == maxVotesAllowed) && + (identical( + other.allowUserSuggestedOptions, + allowUserSuggestedOptions, + ) || + other.allowUserSuggestedOptions == allowUserSuggestedOptions) && + (identical(other.allowAnswers, allowAnswers) || other.allowAnswers == allowAnswers) && + (identical(other.isClosed, isClosed) || other.isClosed == isClosed) && + (identical(other.answersCount, answersCount) || other.answersCount == answersCount) && + const DeepCollectionEquality().equals( + other.voteCountsByOption, + voteCountsByOption, + ) && + const DeepCollectionEquality().equals( + other.latestVotesByOption, + latestVotesByOption, + ) && + const DeepCollectionEquality().equals( + other.latestVotes, + latestVotes, + ) && + const DeepCollectionEquality().equals( + other.latestAnswers, + latestAnswers, + ) && + const DeepCollectionEquality().equals( + other.ownVotesAndAnswers, + ownVotesAndAnswers, + ) && + (identical(other.voteCount, voteCount) || other.voteCount == voteCount) && + const DeepCollectionEquality().equals(other.ownVotes, ownVotes) && + const DeepCollectionEquality().equals( + other.ownAnswers, + ownAnswers, + ) && + (identical(other.createdById, createdById) || other.createdById == createdById) && + (identical(other.createdBy, createdBy) || other.createdBy == createdBy) && + (identical(other.createdAt, createdAt) || other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt) && + const DeepCollectionEquality().equals(other.custom, custom)); + } + + @override + int get hashCode => Object.hashAll([ + runtimeType, + id, + name, + const DeepCollectionEquality().hash(nameI18n), + description, + const DeepCollectionEquality().hash(descriptionI18n), + const DeepCollectionEquality().hash(options), + votingVisibility, + enforceUniqueVote, + maxVotesAllowed, + allowUserSuggestedOptions, + allowAnswers, + isClosed, + answersCount, + const DeepCollectionEquality().hash(voteCountsByOption), + const DeepCollectionEquality().hash(latestVotesByOption), + const DeepCollectionEquality().hash(latestVotes), + const DeepCollectionEquality().hash(latestAnswers), + const DeepCollectionEquality().hash(ownVotesAndAnswers), + voteCount, + const DeepCollectionEquality().hash(ownVotes), + const DeepCollectionEquality().hash(ownAnswers), + createdById, + createdBy, + createdAt, + updatedAt, + const DeepCollectionEquality().hash(custom), + ]); + + @override + String toString() { + return 'Poll(id: $id, name: $name, nameI18n: $nameI18n, description: $description, descriptionI18n: $descriptionI18n, options: $options, votingVisibility: $votingVisibility, enforceUniqueVote: $enforceUniqueVote, maxVotesAllowed: $maxVotesAllowed, allowUserSuggestedOptions: $allowUserSuggestedOptions, allowAnswers: $allowAnswers, isClosed: $isClosed, answersCount: $answersCount, voteCountsByOption: $voteCountsByOption, latestVotesByOption: $latestVotesByOption, latestVotes: $latestVotes, latestAnswers: $latestAnswers, ownVotesAndAnswers: $ownVotesAndAnswers, voteCount: $voteCount, ownVotes: $ownVotes, ownAnswers: $ownAnswers, createdById: $createdById, createdBy: $createdBy, createdAt: $createdAt, updatedAt: $updatedAt, custom: $custom)'; + } +} + +/// @nodoc +abstract mixin class $PollCopyWith<$Res> { + factory $PollCopyWith(Poll value, $Res Function(Poll) _then) = _$PollCopyWithImpl; + @useResult + $Res call({ + String? id, + String name, + Map? nameI18n, + String? description, + Map? descriptionI18n, + List options, + VotingVisibility votingVisibility, + bool enforceUniqueVote, + int? maxVotesAllowed, + bool allowAnswers, + List latestAnswers, + int answersCount, + bool allowUserSuggestedOptions, + bool isClosed, + DateTime? createdAt, + DateTime? updatedAt, + Map voteCountsByOption, + int voteCount, + Map> latestVotesByOption, + String? createdById, + User? createdBy, + List ownVotesAndAnswers, + Map custom, + }); +} + +/// @nodoc +class _$PollCopyWithImpl<$Res> implements $PollCopyWith<$Res> { + _$PollCopyWithImpl(this._self, this._then); + + final Poll _self; + final $Res Function(Poll) _then; + + /// Create a copy of Poll + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = freezed, + Object? name = null, + Object? nameI18n = freezed, + Object? description = freezed, + Object? descriptionI18n = freezed, + Object? options = null, + Object? votingVisibility = null, + Object? enforceUniqueVote = null, + Object? maxVotesAllowed = freezed, + Object? allowAnswers = null, + Object? latestAnswers = null, + Object? answersCount = null, + Object? allowUserSuggestedOptions = null, + Object? isClosed = null, + Object? createdAt = freezed, + Object? updatedAt = freezed, + Object? voteCountsByOption = null, + Object? voteCount = null, + Object? latestVotesByOption = null, + Object? createdById = freezed, + Object? createdBy = freezed, + Object? ownVotesAndAnswers = null, + Object? custom = null, + }) { + return _then( + Poll( + id: freezed == id + ? _self.id! + : id // ignore: cast_nullable_to_non_nullable + as String?, + name: null == name + ? _self.name + : name // ignore: cast_nullable_to_non_nullable + as String, + nameI18n: freezed == nameI18n + ? _self.nameI18n + : nameI18n // ignore: cast_nullable_to_non_nullable + as Map?, + description: freezed == description + ? _self.description + : description // ignore: cast_nullable_to_non_nullable + as String?, + descriptionI18n: freezed == descriptionI18n + ? _self.descriptionI18n + : descriptionI18n // ignore: cast_nullable_to_non_nullable + as Map?, + options: null == options + ? _self.options + : options // ignore: cast_nullable_to_non_nullable + as List, + votingVisibility: null == votingVisibility + ? _self.votingVisibility + : votingVisibility // ignore: cast_nullable_to_non_nullable + as VotingVisibility, + enforceUniqueVote: null == enforceUniqueVote + ? _self.enforceUniqueVote + : enforceUniqueVote // ignore: cast_nullable_to_non_nullable + as bool, + maxVotesAllowed: freezed == maxVotesAllowed + ? _self.maxVotesAllowed + : maxVotesAllowed // ignore: cast_nullable_to_non_nullable + as int?, + allowAnswers: null == allowAnswers + ? _self.allowAnswers + : allowAnswers // ignore: cast_nullable_to_non_nullable + as bool, + latestAnswers: null == latestAnswers + ? _self.latestAnswers + : latestAnswers // ignore: cast_nullable_to_non_nullable + as List, + answersCount: null == answersCount + ? _self.answersCount + : answersCount // ignore: cast_nullable_to_non_nullable + as int, + allowUserSuggestedOptions: null == allowUserSuggestedOptions + ? _self.allowUserSuggestedOptions + : allowUserSuggestedOptions // ignore: cast_nullable_to_non_nullable + as bool, + isClosed: null == isClosed + ? _self.isClosed + : isClosed // ignore: cast_nullable_to_non_nullable + as bool, + createdAt: freezed == createdAt + ? _self.createdAt! + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _self.updatedAt! + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + voteCountsByOption: null == voteCountsByOption + ? _self.voteCountsByOption + : voteCountsByOption // ignore: cast_nullable_to_non_nullable + as Map, + voteCount: null == voteCount + ? _self.voteCount + : voteCount // ignore: cast_nullable_to_non_nullable + as int, + latestVotesByOption: null == latestVotesByOption + ? _self.latestVotesByOption + : latestVotesByOption // ignore: cast_nullable_to_non_nullable + as Map>, + createdById: freezed == createdById + ? _self.createdById + : createdById // ignore: cast_nullable_to_non_nullable + as String?, + createdBy: freezed == createdBy + ? _self.createdBy + : createdBy // ignore: cast_nullable_to_non_nullable + as User?, + ownVotesAndAnswers: null == ownVotesAndAnswers + ? _self.ownVotesAndAnswers + : ownVotesAndAnswers // ignore: cast_nullable_to_non_nullable + as List, + custom: null == custom + ? _self.custom + : custom // ignore: cast_nullable_to_non_nullable + as Map, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/poll.g.dart b/packages/stream_chat/lib/src/core/models/poll.g.dart deleted file mode 100644 index 11927e1b16..0000000000 --- a/packages/stream_chat/lib/src/core/models/poll.g.dart +++ /dev/null @@ -1,76 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'poll.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -Poll _$PollFromJson(Map json) => Poll( - id: json['id'] as String?, - name: json['name'] as String, - nameI18n: (json['name_i18n'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ), - description: json['description'] as String?, - descriptionI18n: (json['description_i18n'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ), - options: (json['options'] as List).map((e) => PollOption.fromJson(e as Map)).toList(), - votingVisibility: - $enumDecodeNullable( - _$VotingVisibilityEnumMap, - json['voting_visibility'], - ) ?? - VotingVisibility.public, - enforceUniqueVote: json['enforce_unique_vote'] as bool? ?? true, - maxVotesAllowed: (json['max_votes_allowed'] as num?)?.toInt(), - allowAnswers: json['allow_answers'] as bool? ?? false, - latestAnswers: - (json['latest_answers'] as List?)?.map((e) => PollVote.fromJson(e as Map)).toList() ?? - const [], - answersCount: (json['answers_count'] as num?)?.toInt() ?? 0, - allowUserSuggestedOptions: json['allow_user_suggested_options'] as bool? ?? false, - isClosed: json['is_closed'] as bool? ?? false, - createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), - voteCountsByOption: - (json['vote_counts_by_option'] as Map?)?.map( - (k, e) => MapEntry(k, (e as num).toInt()), - ) ?? - const {}, - voteCount: (json['vote_count'] as num?)?.toInt() ?? 0, - latestVotesByOption: - (json['latest_votes_by_option'] as Map?)?.map( - (k, e) => MapEntry( - k, - (e as List).map((e) => PollVote.fromJson(e as Map)).toList(), - ), - ) ?? - const {}, - createdById: json['created_by_id'] as String?, - createdBy: json['created_by'] == null ? null : User.fromJson(json['created_by'] as Map), - ownVotesAndAnswers: - (json['own_votes'] as List?)?.map((e) => PollVote.fromJson(e as Map)).toList() ?? - const [], - extraData: json['extra_data'] as Map? ?? const {}, -); - -Map _$PollToJson(Poll instance) => { - 'id': instance.id, - 'name': instance.name, - 'description': instance.description, - 'options': instance.options.map((e) => e.toJson()).toList(), - 'voting_visibility': _$VotingVisibilityEnumMap[instance.votingVisibility]!, - 'enforce_unique_vote': instance.enforceUniqueVote, - 'max_votes_allowed': instance.maxVotesAllowed, - 'allow_user_suggested_options': instance.allowUserSuggestedOptions, - 'allow_answers': instance.allowAnswers, - 'is_closed': instance.isClosed, - 'extra_data': instance.extraData, -}; - -const _$VotingVisibilityEnumMap = { - VotingVisibility.anonymous: 'anonymous', - VotingVisibility.public: 'public', -}; diff --git a/packages/stream_chat/lib/src/core/models/poll_option.dart b/packages/stream_chat/lib/src/core/models/poll_option.dart index bc6bd1a791..1914207e11 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.dart @@ -1,38 +1,38 @@ -import 'package:equatable/equatable.dart'; -import 'package:json_annotation/json_annotation.dart'; -import '../util/serializer.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; -part 'poll_option.g.dart'; - -class _NullConst { - const _NullConst(); -} +import '../../db/data_serializable.dart'; -const _nullConst = _NullConst(); +part 'poll_option.freezed.dart'; +part 'poll_option.g.dart'; -/// {@template streamPollOption} -/// A model class representing a poll option. -/// {@endtemplate} -@JsonSerializable() -class PollOption extends Equatable { - /// {@macro streamPollOption} +/// One of the choices a poll offers to vote on. +@freezed +// TODO(openapi-migration): remove in group 10 +@DataSerializable(includeIfNull: false) +class PollOption with _$PollOption { + /// Creates a new [PollOption]. + /// + /// An option that is about to be added to a poll has no [id] yet; one is + /// assigned when the poll or the option is created. const PollOption({ this.id, required this.text, this.textI18n, - this.extraData = const {}, + this.custom = const {}, }); - /// Create a new instance from a json - factory PollOption.fromJson(Map json) => _$PollOptionFromJson( - Serializer.moveToExtraDataFromRoot(json, topLevelFields), - ); + /// Creates a [PollOption] from the offline-database format written by + /// [toData]. + /// + /// It is not a codec for API payloads. + factory PollOption.fromData(Map json) => _$PollOptionFromJson(json); - /// The unique identifier of the poll option. - @JsonKey(includeIfNull: false) + /// The unique identifier of this option, or null before it is created. + @override final String? id; - /// The text describing the poll option. + /// The text shown for this option. + @override final String text; /// The translations of [text], keyed as `_text`, plus the @@ -40,37 +40,15 @@ class PollOption extends Equatable { /// /// Filled in by the server when the poll is sent to a channel with /// automatic translation enabled. - @JsonKey(includeToJson: false) + @override final Map? textI18n; - /// Map of custom poll option extraData - final Map extraData; - - /// Serialize to json - Map toJson() => Serializer.moveFromExtraDataToRoot(_$PollOptionToJson(this)); - - /// Creates a copy of [PollOption] with specified attributes overridden. - PollOption copyWith({ - Object? id = _nullConst, - String? text, - Map? textI18n, - Map? extraData, - }) => PollOption( - id: id == _nullConst ? this.id : id as String?, - text: text ?? this.text, - textI18n: textI18n ?? this.textI18n, - extraData: extraData ?? this.extraData, - ); + /// Custom data attached to this option. + @override + final Map custom; - /// Known top level fields. + /// Serializes this option to the format `stream_chat_persistence` stores. /// - /// Useful for [Serializer] methods. - static const topLevelFields = [ - 'id', - 'text', - 'text_i18n', - ]; - - @override - List get props => [id, text, textI18n]; + /// It is not a codec for API payloads. + Map toData() => _$PollOptionToJson(this); } diff --git a/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart b/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart new file mode 100644 index 0000000000..3a66a6d918 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/poll_option.freezed.dart @@ -0,0 +1,106 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'poll_option.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$PollOption { + String? get id; + String get text; + Map? get textI18n; + Map get custom; + + /// Create a copy of PollOption + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollOptionCopyWith get copyWith => _$PollOptionCopyWithImpl(this as PollOption, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is PollOption && + (identical(other.id, id) || other.id == id) && + (identical(other.text, text) || other.text == text) && + const DeepCollectionEquality().equals(other.textI18n, textI18n) && + const DeepCollectionEquality().equals(other.custom, custom)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + id, + text, + const DeepCollectionEquality().hash(textI18n), + const DeepCollectionEquality().hash(custom), + ); + + @override + String toString() { + return 'PollOption(id: $id, text: $text, textI18n: $textI18n, custom: $custom)'; + } +} + +/// @nodoc +abstract mixin class $PollOptionCopyWith<$Res> { + factory $PollOptionCopyWith( + PollOption value, + $Res Function(PollOption) _then, + ) = _$PollOptionCopyWithImpl; + @useResult + $Res call({ + String? id, + String text, + Map? textI18n, + Map custom, + }); +} + +/// @nodoc +class _$PollOptionCopyWithImpl<$Res> implements $PollOptionCopyWith<$Res> { + _$PollOptionCopyWithImpl(this._self, this._then); + + final PollOption _self; + final $Res Function(PollOption) _then; + + /// Create a copy of PollOption + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = freezed, + Object? text = null, + Object? textI18n = freezed, + Object? custom = null, + }) { + return _then( + PollOption( + id: freezed == id + ? _self.id + : id // ignore: cast_nullable_to_non_nullable + as String?, + text: null == text + ? _self.text + : text // ignore: cast_nullable_to_non_nullable + as String, + textI18n: freezed == textI18n + ? _self.textI18n + : textI18n // ignore: cast_nullable_to_non_nullable + as Map?, + custom: null == custom + ? _self.custom + : custom // ignore: cast_nullable_to_non_nullable + as Map, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/poll_option.g.dart b/packages/stream_chat/lib/src/core/models/poll_option.g.dart index ba01b08839..7409fb2928 100644 --- a/packages/stream_chat/lib/src/core/models/poll_option.g.dart +++ b/packages/stream_chat/lib/src/core/models/poll_option.g.dart @@ -12,11 +12,12 @@ PollOption _$PollOptionFromJson(Map json) => PollOption( textI18n: (json['text_i18n'] as Map?)?.map( (k, e) => MapEntry(k, e as String), ), - extraData: json['extra_data'] as Map? ?? const {}, + custom: json['custom'] as Map? ?? const {}, ); Map _$PollOptionToJson(PollOption instance) => { 'id': ?instance.id, 'text': instance.text, - 'extra_data': instance.extraData, + 'text_i18n': ?instance.textI18n, + 'custom': instance.custom, }; diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.dart b/packages/stream_chat/lib/src/core/models/poll_vote.dart index 21ebab6c48..3bc3f2fe45 100644 --- a/packages/stream_chat/lib/src/core/models/poll_vote.dart +++ b/packages/stream_chat/lib/src/core/models/poll_vote.dart @@ -1,17 +1,20 @@ -import 'package:equatable/equatable.dart'; -import 'package:json_annotation/json_annotation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:stream_core/stream_core.dart' show Filter, FilterField, Sort, SortField; import 'user.dart'; -part 'poll_vote.g.dart'; +part 'poll_vote.freezed.dart'; -/// {@template streamPollVote} -/// A model class representing a poll vote. -/// {@endtemplate} -@JsonSerializable() -class PollVote extends Equatable { - /// {@macro streamPollVote} +/// A user's vote for an option of a poll, or their free-form answer to it. +/// +/// A vote carries an [optionId] and an answer carries [answerText]; [isAnswer] +/// tells the two apart. +@freezed +class PollVote with _$PollVote { + /// Creates a new [PollVote]. + /// + /// Either [optionId] or [answerText] must be given. [createdAt] and + /// [updatedAt] default to the current time. PollVote({ this.id, this.pollId, @@ -26,23 +29,25 @@ class PollVote extends Equatable { optionId != null || answerText != null, 'Either optionId or answerText must be provided', ), - isAnswer = answerText != null, createdAt = createdAt ?? DateTime.now(), updatedAt = updatedAt ?? DateTime.now(); - /// Create a new instance from a json - factory PollVote.fromJson(Map json) => _$PollVoteFromJson(json); - - /// The unique identifier of the poll vote. - @JsonKey(includeIfNull: false) + /// The unique identifier of this vote, or null before it is cast. + @override final String? id; - /// The unique identifier of the option selected in the poll. - @JsonKey(includeIfNull: false) + /// The unique identifier of the poll this vote belongs to. + @override + final String? pollId; + + /// The unique identifier of the option this vote selects. + /// + /// An empty string for an answer, which selects no option. + @override final String? optionId; - /// The text of the answer provided in the poll. - @JsonKey(includeIfNull: false) + /// The text of this answer, or null for a vote. + @override final String? answerText; /// The translations of [answerText], keyed as `_text`, plus the @@ -50,72 +55,31 @@ class PollVote extends Equatable { /// /// Filled in by the server when the answer is added to a poll in a channel /// with automatic translation enabled. - @JsonKey(includeToJson: false) + @override final Map? answerTextI18n; - /// If true, the vote is an answer. - @JsonKey(includeToJson: false) - final bool isAnswer; - - /// The unique identifier of the poll the vote belongs to. - @JsonKey(includeToJson: false) - final String? pollId; - - /// The date when the poll vote was created. - @JsonKey(includeToJson: false) + /// The date this vote was cast. + @override final DateTime createdAt; - /// The date when the poll vote was last updated. - @JsonKey(includeToJson: false) + /// The date this vote was last changed. + @override final DateTime updatedAt; - /// The unique identifier of the user who voted. - @JsonKey(includeToJson: false) + /// The unique identifier of the user who cast this vote. + /// + /// May be null in an anonymous poll, even on the current user's own vote. + @override final String? userId; - /// The user who casted the vote. - @JsonKey(includeToJson: false) + /// The user who cast this vote. + /// + /// May be null in an anonymous poll, even on the current user's own vote. + @override final User? user; - /// Serialize to json - Map toJson() => _$PollVoteToJson(this); - - /// Creates a copy of [PollVote] with specified attributes overridden. - PollVote copyWith({ - String? id, - String? pollId, - String? optionId, - String? answerText, - Map? answerTextI18n, - DateTime? createdAt, - DateTime? updatedAt, - String? userId, - User? user, - }) => PollVote( - id: id ?? this.id, - pollId: pollId ?? this.pollId, - optionId: optionId ?? this.optionId, - answerText: answerText ?? this.answerText, - answerTextI18n: answerTextI18n ?? this.answerTextI18n, - createdAt: createdAt ?? this.createdAt, - updatedAt: updatedAt ?? this.updatedAt, - userId: userId ?? this.userId, - user: user ?? this.user, - ); - - @override - List get props => [ - id, - pollId, - optionId, - isAnswer, - answerText, - answerTextI18n, - createdAt, - updatedAt, - userId, - user, - ]; + /// Whether this is a free-form answer rather than a vote for an option. + bool get isAnswer => answerText != null; } /// A filter for a poll vote query. diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart b/packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart new file mode 100644 index 0000000000..51ff240cbd --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/poll_vote.freezed.dart @@ -0,0 +1,151 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'poll_vote.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$PollVote { + String? get id; + String? get pollId; + String? get optionId; + String? get answerText; + Map? get answerTextI18n; + DateTime get createdAt; + DateTime get updatedAt; + String? get userId; + User? get user; + + /// Create a copy of PollVote + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollVoteCopyWith get copyWith => _$PollVoteCopyWithImpl(this as PollVote, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is PollVote && + (identical(other.id, id) || other.id == id) && + (identical(other.pollId, pollId) || other.pollId == pollId) && + (identical(other.optionId, optionId) || other.optionId == optionId) && + (identical(other.answerText, answerText) || other.answerText == answerText) && + const DeepCollectionEquality().equals( + other.answerTextI18n, + answerTextI18n, + ) && + (identical(other.createdAt, createdAt) || other.createdAt == createdAt) && + (identical(other.updatedAt, updatedAt) || other.updatedAt == updatedAt) && + (identical(other.userId, userId) || other.userId == userId) && + (identical(other.user, user) || other.user == user)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + id, + pollId, + optionId, + answerText, + const DeepCollectionEquality().hash(answerTextI18n), + createdAt, + updatedAt, + userId, + user, + ); + + @override + String toString() { + return 'PollVote(id: $id, pollId: $pollId, optionId: $optionId, answerText: $answerText, answerTextI18n: $answerTextI18n, createdAt: $createdAt, updatedAt: $updatedAt, userId: $userId, user: $user)'; + } +} + +/// @nodoc +abstract mixin class $PollVoteCopyWith<$Res> { + factory $PollVoteCopyWith(PollVote value, $Res Function(PollVote) _then) = _$PollVoteCopyWithImpl; + @useResult + $Res call({ + String? id, + String? pollId, + String? optionId, + String? answerText, + Map? answerTextI18n, + DateTime? createdAt, + DateTime? updatedAt, + String? userId, + User? user, + }); +} + +/// @nodoc +class _$PollVoteCopyWithImpl<$Res> implements $PollVoteCopyWith<$Res> { + _$PollVoteCopyWithImpl(this._self, this._then); + + final PollVote _self; + final $Res Function(PollVote) _then; + + /// Create a copy of PollVote + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = freezed, + Object? pollId = freezed, + Object? optionId = freezed, + Object? answerText = freezed, + Object? answerTextI18n = freezed, + Object? createdAt = freezed, + Object? updatedAt = freezed, + Object? userId = freezed, + Object? user = freezed, + }) { + return _then( + PollVote( + id: freezed == id + ? _self.id + : id // ignore: cast_nullable_to_non_nullable + as String?, + pollId: freezed == pollId + ? _self.pollId + : pollId // ignore: cast_nullable_to_non_nullable + as String?, + optionId: freezed == optionId + ? _self.optionId + : optionId // ignore: cast_nullable_to_non_nullable + as String?, + answerText: freezed == answerText + ? _self.answerText + : answerText // ignore: cast_nullable_to_non_nullable + as String?, + answerTextI18n: freezed == answerTextI18n + ? _self.answerTextI18n + : answerTextI18n // ignore: cast_nullable_to_non_nullable + as Map?, + createdAt: freezed == createdAt + ? _self.createdAt! + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + updatedAt: freezed == updatedAt + ? _self.updatedAt! + : updatedAt // ignore: cast_nullable_to_non_nullable + as DateTime?, + userId: freezed == userId + ? _self.userId + : userId // ignore: cast_nullable_to_non_nullable + as String?, + user: freezed == user + ? _self.user + : user // ignore: cast_nullable_to_non_nullable + as User?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/poll_vote.g.dart b/packages/stream_chat/lib/src/core/models/poll_vote.g.dart deleted file mode 100644 index b8ce2074e5..0000000000 --- a/packages/stream_chat/lib/src/core/models/poll_vote.g.dart +++ /dev/null @@ -1,27 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'poll_vote.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -PollVote _$PollVoteFromJson(Map json) => PollVote( - id: json['id'] as String?, - pollId: json['poll_id'] as String?, - optionId: json['option_id'] as String?, - answerText: json['answer_text'] as String?, - answerTextI18n: (json['answer_text_i18n'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ), - createdAt: json['created_at'] == null ? null : DateTime.parse(json['created_at'] as String), - updatedAt: json['updated_at'] == null ? null : DateTime.parse(json['updated_at'] as String), - userId: json['user_id'] as String?, - user: json['user'] == null ? null : User.fromJson(json['user'] as Map), -); - -Map _$PollVoteToJson(PollVote instance) => { - 'id': ?instance.id, - 'option_id': ?instance.optionId, - 'answer_text': ?instance.answerText, -}; diff --git a/packages/stream_chat/lib/src/core/models/response/poll_option_response.dart b/packages/stream_chat/lib/src/core/models/response/poll_option_response.dart new file mode 100644 index 0000000000..66d4ad99fd --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/poll_option_response.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../poll_option.dart'; + +part 'poll_option_response.freezed.dart'; + +/// The result of creating, fetching or updating an option of a poll. +@freezed +class PollOptionResponse with _$PollOptionResponse { + /// Creates a new [PollOptionResponse]. + const PollOptionResponse({ + required this.duration, + required this.pollOption, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The option as it stands after the request. + @override + final PollOption pollOption; +} diff --git a/packages/stream_chat/lib/src/core/models/response/poll_option_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/poll_option_response.freezed.dart new file mode 100644 index 0000000000..9245946901 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/poll_option_response.freezed.dart @@ -0,0 +1,77 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'poll_option_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$PollOptionResponse { + String get duration; + PollOption get pollOption; + + /// Create a copy of PollOptionResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollOptionResponseCopyWith get copyWith => + _$PollOptionResponseCopyWithImpl(this as PollOptionResponse, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is PollOptionResponse && + (identical(other.duration, duration) || other.duration == duration) && + (identical(other.pollOption, pollOption) || other.pollOption == pollOption)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, pollOption); + + @override + String toString() { + return 'PollOptionResponse(duration: $duration, pollOption: $pollOption)'; + } +} + +/// @nodoc +abstract mixin class $PollOptionResponseCopyWith<$Res> { + factory $PollOptionResponseCopyWith(PollOptionResponse value, $Res Function(PollOptionResponse) _then) = + _$PollOptionResponseCopyWithImpl; + @useResult + $Res call({String duration, PollOption pollOption}); +} + +/// @nodoc +class _$PollOptionResponseCopyWithImpl<$Res> implements $PollOptionResponseCopyWith<$Res> { + _$PollOptionResponseCopyWithImpl(this._self, this._then); + + final PollOptionResponse _self; + final $Res Function(PollOptionResponse) _then; + + /// Create a copy of PollOptionResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? pollOption = null}) { + return _then( + PollOptionResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + pollOption: null == pollOption + ? _self.pollOption + : pollOption // ignore: cast_nullable_to_non_nullable + as PollOption, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/poll_response.dart b/packages/stream_chat/lib/src/core/models/response/poll_response.dart new file mode 100644 index 0000000000..b21cf96a08 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/poll_response.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../poll.dart'; + +part 'poll_response.freezed.dart'; + +/// The result of creating, fetching or updating a poll. +@freezed +class PollResponse with _$PollResponse { + /// Creates a new [PollResponse]. + const PollResponse({ + required this.duration, + required this.poll, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The poll as it stands after the request. + @override + final Poll poll; +} diff --git a/packages/stream_chat/lib/src/core/models/response/poll_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/poll_response.freezed.dart new file mode 100644 index 0000000000..241268a0d7 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/poll_response.freezed.dart @@ -0,0 +1,76 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'poll_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$PollResponse { + String get duration; + Poll get poll; + + /// Create a copy of PollResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollResponseCopyWith get copyWith => + _$PollResponseCopyWithImpl(this as PollResponse, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is PollResponse && + (identical(other.duration, duration) || other.duration == duration) && + (identical(other.poll, poll) || other.poll == poll)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, poll); + + @override + String toString() { + return 'PollResponse(duration: $duration, poll: $poll)'; + } +} + +/// @nodoc +abstract mixin class $PollResponseCopyWith<$Res> { + factory $PollResponseCopyWith(PollResponse value, $Res Function(PollResponse) _then) = _$PollResponseCopyWithImpl; + @useResult + $Res call({String duration, Poll poll}); +} + +/// @nodoc +class _$PollResponseCopyWithImpl<$Res> implements $PollResponseCopyWith<$Res> { + _$PollResponseCopyWithImpl(this._self, this._then); + + final PollResponse _self; + final $Res Function(PollResponse) _then; + + /// Create a copy of PollResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? poll = null}) { + return _then( + PollResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + poll: null == poll + ? _self.poll + : poll // ignore: cast_nullable_to_non_nullable + as Poll, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart b/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart new file mode 100644 index 0000000000..151b4c3d75 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/poll_vote_response.dart @@ -0,0 +1,23 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../poll_vote.dart'; + +part 'poll_vote_response.freezed.dart'; + +/// The result of casting or removing a vote or an answer on a poll. +@freezed +class PollVoteResponse with _$PollVoteResponse { + /// Creates a new [PollVoteResponse]. + const PollVoteResponse({ + required this.duration, + this.vote, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The vote that was cast or removed. + @override + final PollVote? vote; +} diff --git a/packages/stream_chat/lib/src/core/models/response/poll_vote_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/poll_vote_response.freezed.dart new file mode 100644 index 0000000000..88e7d5cb72 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/poll_vote_response.freezed.dart @@ -0,0 +1,77 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'poll_vote_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$PollVoteResponse { + String get duration; + PollVote? get vote; + + /// Create a copy of PollVoteResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $PollVoteResponseCopyWith get copyWith => + _$PollVoteResponseCopyWithImpl(this as PollVoteResponse, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is PollVoteResponse && + (identical(other.duration, duration) || other.duration == duration) && + (identical(other.vote, vote) || other.vote == vote)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, vote); + + @override + String toString() { + return 'PollVoteResponse(duration: $duration, vote: $vote)'; + } +} + +/// @nodoc +abstract mixin class $PollVoteResponseCopyWith<$Res> { + factory $PollVoteResponseCopyWith(PollVoteResponse value, $Res Function(PollVoteResponse) _then) = + _$PollVoteResponseCopyWithImpl; + @useResult + $Res call({String duration, PollVote? vote}); +} + +/// @nodoc +class _$PollVoteResponseCopyWithImpl<$Res> implements $PollVoteResponseCopyWith<$Res> { + _$PollVoteResponseCopyWithImpl(this._self, this._then); + + final PollVoteResponse _self; + final $Res Function(PollVoteResponse) _then; + + /// Create a copy of PollVoteResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? vote = freezed}) { + return _then( + PollVoteResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + vote: freezed == vote + ? _self.vote + : vote // ignore: cast_nullable_to_non_nullable + as PollVote?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/query_poll_votes_response.dart b/packages/stream_chat/lib/src/core/models/response/query_poll_votes_response.dart new file mode 100644 index 0000000000..0a8c30cd83 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/query_poll_votes_response.dart @@ -0,0 +1,33 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../poll_vote.dart'; + +part 'query_poll_votes_response.freezed.dart'; + +/// One page of the votes and answers of a poll matching a query. +@freezed +class QueryPollVotesResponse with _$QueryPollVotesResponse { + /// Creates a new [QueryPollVotesResponse]. + const QueryPollVotesResponse({ + required this.duration, + required this.votes, + this.next, + this.prev, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The votes and answers on this page. + @override + final List votes; + + /// The cursor for the next page, or null on the last page. + @override + final String? next; + + /// The cursor for the previous page, or null on the first page. + @override + final String? prev; +} diff --git a/packages/stream_chat/lib/src/core/models/response/query_poll_votes_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/query_poll_votes_response.freezed.dart new file mode 100644 index 0000000000..45de37e4f4 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/query_poll_votes_response.freezed.dart @@ -0,0 +1,89 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'query_poll_votes_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$QueryPollVotesResponse { + String get duration; + List get votes; + String? get next; + String? get prev; + + /// Create a copy of QueryPollVotesResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $QueryPollVotesResponseCopyWith get copyWith => + _$QueryPollVotesResponseCopyWithImpl(this as QueryPollVotesResponse, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is QueryPollVotesResponse && + (identical(other.duration, duration) || other.duration == duration) && + const DeepCollectionEquality().equals(other.votes, votes) && + (identical(other.next, next) || other.next == next) && + (identical(other.prev, prev) || other.prev == prev)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, const DeepCollectionEquality().hash(votes), next, prev); + + @override + String toString() { + return 'QueryPollVotesResponse(duration: $duration, votes: $votes, next: $next, prev: $prev)'; + } +} + +/// @nodoc +abstract mixin class $QueryPollVotesResponseCopyWith<$Res> { + factory $QueryPollVotesResponseCopyWith(QueryPollVotesResponse value, $Res Function(QueryPollVotesResponse) _then) = + _$QueryPollVotesResponseCopyWithImpl; + @useResult + $Res call({String duration, List votes, String? next, String? prev}); +} + +/// @nodoc +class _$QueryPollVotesResponseCopyWithImpl<$Res> implements $QueryPollVotesResponseCopyWith<$Res> { + _$QueryPollVotesResponseCopyWithImpl(this._self, this._then); + + final QueryPollVotesResponse _self; + final $Res Function(QueryPollVotesResponse) _then; + + /// Create a copy of QueryPollVotesResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? votes = null, Object? next = freezed, Object? prev = freezed}) { + return _then( + QueryPollVotesResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + votes: null == votes + ? _self.votes + : votes // ignore: cast_nullable_to_non_nullable + as List, + next: freezed == next + ? _self.next + : next // ignore: cast_nullable_to_non_nullable + as String?, + prev: freezed == prev + ? _self.prev + : prev // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/response/query_polls_response.dart b/packages/stream_chat/lib/src/core/models/response/query_polls_response.dart new file mode 100644 index 0000000000..da87101885 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/query_polls_response.dart @@ -0,0 +1,33 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../poll.dart'; + +part 'query_polls_response.freezed.dart'; + +/// One page of the polls matching a query. +@freezed +class QueryPollsResponse with _$QueryPollsResponse { + /// Creates a new [QueryPollsResponse]. + const QueryPollsResponse({ + required this.duration, + required this.polls, + this.next, + this.prev, + }); + + /// How long the request took to handle, such as `4.21ms`. + @override + final String duration; + + /// The polls on this page. + @override + final List polls; + + /// The cursor for the next page, or null on the last page. + @override + final String? next; + + /// The cursor for the previous page, or null on the first page. + @override + final String? prev; +} diff --git a/packages/stream_chat/lib/src/core/models/response/query_polls_response.freezed.dart b/packages/stream_chat/lib/src/core/models/response/query_polls_response.freezed.dart new file mode 100644 index 0000000000..fa1b10518c --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/response/query_polls_response.freezed.dart @@ -0,0 +1,89 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'query_polls_response.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +/// @nodoc +mixin _$QueryPollsResponse { + String get duration; + List get polls; + String? get next; + String? get prev; + + /// Create a copy of QueryPollsResponse + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @pragma('vm:prefer-inline') + $QueryPollsResponseCopyWith get copyWith => + _$QueryPollsResponseCopyWithImpl(this as QueryPollsResponse, _$identity); + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is QueryPollsResponse && + (identical(other.duration, duration) || other.duration == duration) && + const DeepCollectionEquality().equals(other.polls, polls) && + (identical(other.next, next) || other.next == next) && + (identical(other.prev, prev) || other.prev == prev)); + } + + @override + int get hashCode => Object.hash(runtimeType, duration, const DeepCollectionEquality().hash(polls), next, prev); + + @override + String toString() { + return 'QueryPollsResponse(duration: $duration, polls: $polls, next: $next, prev: $prev)'; + } +} + +/// @nodoc +abstract mixin class $QueryPollsResponseCopyWith<$Res> { + factory $QueryPollsResponseCopyWith(QueryPollsResponse value, $Res Function(QueryPollsResponse) _then) = + _$QueryPollsResponseCopyWithImpl; + @useResult + $Res call({String duration, List polls, String? next, String? prev}); +} + +/// @nodoc +class _$QueryPollsResponseCopyWithImpl<$Res> implements $QueryPollsResponseCopyWith<$Res> { + _$QueryPollsResponseCopyWithImpl(this._self, this._then); + + final QueryPollsResponse _self; + final $Res Function(QueryPollsResponse) _then; + + /// Create a copy of QueryPollsResponse + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({Object? duration = null, Object? polls = null, Object? next = freezed, Object? prev = freezed}) { + return _then( + QueryPollsResponse( + duration: null == duration + ? _self.duration + : duration // ignore: cast_nullable_to_non_nullable + as String, + polls: null == polls + ? _self.polls + : polls // ignore: cast_nullable_to_non_nullable + as List, + next: freezed == next + ? _self.next + : next // ignore: cast_nullable_to_non_nullable + as String?, + prev: freezed == prev + ? _self.prev + : prev // ignore: cast_nullable_to_non_nullable + as String?, + ), + ); + } +} diff --git a/packages/stream_chat/lib/src/core/models/voting_visibility.dart b/packages/stream_chat/lib/src/core/models/voting_visibility.dart new file mode 100644 index 0000000000..fec15626b0 --- /dev/null +++ b/packages/stream_chat/lib/src/core/models/voting_visibility.dart @@ -0,0 +1,12 @@ +/// The rule for who can see which option each user voted for in a poll. +/// +/// A visibility without a constant here can still be named by wrapping its +/// value: `VotingVisibility('members_only')`. +extension type const VotingVisibility(String _) implements String { + /// A visibility where votes are counted, but who cast them is hidden from + /// other users. + static const anonymous = VotingVisibility('anonymous'); + + /// A visibility where everyone can see who voted for which option. + static const public = VotingVisibility('public'); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/filter_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/filter_mapper.dart new file mode 100644 index 0000000000..5961fc7150 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/filter_mapper.dart @@ -0,0 +1,7 @@ +import 'package:stream_core/stream_core.dart' show Filter; + +/// Maps a [Filter] to the filter of a generated request. +extension FilterRequestMapper on Filter { + /// Converts this filter into the map a generated request takes. + Map toRequest() => toJson(); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart new file mode 100644 index 0000000000..ff36b3be76 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/polls_mapper.dart @@ -0,0 +1,174 @@ +import '../../../open_api/api.dart' as api; +import '../../core/models/poll.dart'; +import '../../core/models/poll_option.dart'; +import '../../core/models/poll_vote.dart'; +import '../../core/models/response/poll_option_response.dart'; +import '../../core/models/response/poll_response.dart'; +import '../../core/models/response/poll_vote_response.dart'; +import '../../core/models/response/query_poll_votes_response.dart'; +import '../../core/models/response/query_polls_response.dart'; +import '../../core/models/voting_visibility.dart'; +import 'user_mapper.dart'; + +/// Maps a generated [api.PollResponseData] to a [Poll]. +extension PollResponseDataMapper on api.PollResponseData { + /// Converts this poll into a [Poll]. + Poll toModel() => Poll( + id: id, + name: name, + nameI18n: nameI18n, + description: description, + descriptionI18n: descriptionI18n, + options: [for (final option in options) option.toModel()], + votingVisibility: VotingVisibility(votingVisibility), + enforceUniqueVote: enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed, + allowAnswers: allowAnswers, + latestAnswers: [for (final answer in latestAnswers) answer.toModel()], + answersCount: answersCount, + allowUserSuggestedOptions: allowUserSuggestedOptions, + isClosed: isClosed ?? false, + createdAt: createdAt, + updatedAt: updatedAt, + voteCountsByOption: voteCountsByOption, + voteCount: voteCount, + latestVotesByOption: { + for (final MapEntry(key: optionId, value: votes) in latestVotesByOption.entries) + optionId: [for (final vote in votes) vote.toModel()], + }, + createdById: createdById, + createdBy: createdBy?.toModel(), + ownVotesAndAnswers: [for (final vote in ownVotes) vote.toModel()], + custom: custom, + ); +} + +/// Maps a generated [api.PollOptionResponseData] to a [PollOption]. +extension PollOptionResponseDataMapper on api.PollOptionResponseData { + /// Converts this option into a [PollOption]. + PollOption toModel() => PollOption( + id: id, + text: text, + textI18n: textI18n, + custom: custom, + ); +} + +/// Maps a generated [api.PollVoteResponseData] to a [PollVote]. +extension PollVoteResponseDataMapper on api.PollVoteResponseData { + /// Converts this vote into a [PollVote]. + PollVote toModel() => PollVote( + id: id, + pollId: pollId, + optionId: optionId, + answerText: answerText, + answerTextI18n: answerTextI18n, + createdAt: createdAt, + updatedAt: updatedAt, + userId: userId, + user: user?.toModel(), + ); +} + +/// Maps a generated [api.PollResponse] to a [PollResponse]. +extension PollResponseMapper on api.PollResponse { + /// Converts this response into a [PollResponse]. + PollResponse toModel() => PollResponse(duration: duration, poll: poll.toModel()); +} + +/// Maps a generated [api.PollOptionResponse] to a [PollOptionResponse]. +extension PollOptionResponseMapper on api.PollOptionResponse { + /// Converts this response into a [PollOptionResponse]. + PollOptionResponse toModel() => PollOptionResponse(duration: duration, pollOption: pollOption.toModel()); +} + +/// Maps a generated [api.PollVoteResponse] to a [PollVoteResponse]. +extension PollVoteResponseMapper on api.PollVoteResponse { + /// Converts this response into a [PollVoteResponse]. + PollVoteResponse toModel() => PollVoteResponse(duration: duration, vote: vote?.toModel()); +} + +/// Maps a generated [api.QueryPollsResponse] to a [QueryPollsResponse]. +extension QueryPollsResponseMapper on api.QueryPollsResponse { + /// Converts this response into a [QueryPollsResponse]. + QueryPollsResponse toModel() => QueryPollsResponse( + duration: duration, + polls: [for (final poll in polls) poll.toModel()], + next: next, + prev: prev, + ); +} + +/// Maps a generated [api.PollVotesResponse] to a [QueryPollVotesResponse]. +extension PollVotesResponseMapper on api.PollVotesResponse { + /// Converts this response into a [QueryPollVotesResponse]. + QueryPollVotesResponse toModel() => QueryPollVotesResponse( + duration: duration, + votes: [for (final vote in votes) vote.toModel()], + next: next, + prev: prev, + ); +} + +/// Maps a [Poll] to the generated requests that create and update it. +/// +/// Only the settings of the poll are sent; the vote summary is not. +extension PollRequestMapper on Poll { + /// Converts this poll into an [api.CreatePollRequest]. + /// + /// The ids of the [options] are left out: every option gets a new one when + /// the poll is created. + api.CreatePollRequest toCreatePollRequest() => api.CreatePollRequest( + id: id, + name: name, + description: description, + options: [for (final option in options) option.toPollOptionInput()], + votingVisibility: api.CreatePollRequestVotingVisibility.fromJson(votingVisibility), + enforceUniqueVote: enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed, + allowAnswers: allowAnswers, + allowUserSuggestedOptions: allowUserSuggestedOptions, + isClosed: isClosed, + custom: custom, + ); + + /// Converts this poll into an [api.UpdatePollRequest]. + api.UpdatePollRequest toUpdatePollRequest() => api.UpdatePollRequest( + id: id, + name: name, + description: description, + options: [for (final option in options) option.toPollOptionRequest()], + votingVisibility: api.UpdatePollRequestVotingVisibility.fromJson(votingVisibility), + enforceUniqueVote: enforceUniqueVote, + maxVotesAllowed: maxVotesAllowed, + allowAnswers: allowAnswers, + allowUserSuggestedOptions: allowUserSuggestedOptions, + isClosed: isClosed, + custom: custom, + ); +} + +/// Maps a [PollOption] to the generated requests that create and update it. +extension PollOptionRequestMapper on PollOption { + /// Converts this option into an [api.PollOptionInput], an option of a poll + /// being created. + api.PollOptionInput toPollOptionInput() => api.PollOptionInput(text: text, custom: custom); + + /// Converts this option into an [api.PollOptionRequest], an option of a poll + /// being updated. + /// + /// An option without an [id] is sent with an empty one. + api.PollOptionRequest toPollOptionRequest() => api.PollOptionRequest(id: id ?? '', text: text, custom: custom); + + /// Converts this option into an [api.CreatePollOptionRequest]. + api.CreatePollOptionRequest toCreatePollOptionRequest() => api.CreatePollOptionRequest( + text: text, + custom: custom, + ); + + /// Converts this option into an [api.UpdatePollOptionRequest]. + /// + /// An option without an [id] is sent with an empty one. + api.UpdatePollOptionRequest toUpdatePollOptionRequest() => + api.UpdatePollOptionRequest(id: id ?? '', text: text, custom: custom); +} diff --git a/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart b/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart new file mode 100644 index 0000000000..86d0fdd050 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/mapper/sort_mapper.dart @@ -0,0 +1,14 @@ +import 'package:stream_core/stream_core.dart' show Sort; + +import '../../../open_api/api.dart' as api; + +/// Maps a [Sort] to the generated [api.SortParamRequest]. +extension SortRequestMapper on Sort { + /// Converts this sort into an [api.SortParamRequest]. + api.SortParamRequest toRequest() { + return api.SortParamRequest( + field: field.remote, + direction: direction.value, + ); + } +} diff --git a/packages/stream_chat/lib/src/repository/polls_repository.dart b/packages/stream_chat/lib/src/repository/polls_repository.dart new file mode 100644 index 0000000000..5ae1780946 --- /dev/null +++ b/packages/stream_chat/lib/src/repository/polls_repository.dart @@ -0,0 +1,191 @@ +import 'package:stream_core/stream_core.dart' show PatternMatching, Result; + +import '../../open_api/api.dart' as api; +import '../core/models/poll.dart'; +import '../core/models/poll_option.dart'; +import '../core/models/poll_vote.dart'; +import '../core/models/response/poll_option_response.dart'; +import '../core/models/response/poll_response.dart'; +import '../core/models/response/poll_vote_response.dart'; +import '../core/models/response/query_poll_votes_response.dart'; +import '../core/models/response/query_polls_response.dart'; +import 'mapper/filter_mapper.dart'; +import 'mapper/polls_mapper.dart'; +import 'mapper/result_mapper.dart'; +import 'mapper/sort_mapper.dart'; + +/// Repository dedicated to poll operations. +class PollsRepository { + /// Initialize a new polls repository. + const PollsRepository(this._api); + + final api.DefaultApi _api; + + /// Creates [poll] with its settings and options. + Future> createPoll(Poll poll) async { + final result = await _api.createPoll(createPollRequest: poll.toCreatePollRequest()); + + return result.map((response) => response.toModel()); + } + + /// Fetches the poll with the id [pollId]. + Future> getPoll(String pollId) async { + final result = await _api.getPoll(pollId: pollId); + + return result.map((response) => response.toModel()); + } + + /// Updates [poll] to match the settings and options it carries. + /// + /// [Poll.options] becomes the poll's full list of options, each matched by + /// its id. + Future> updatePoll(Poll poll) async { + final result = await _api.updatePoll(updatePollRequest: poll.toUpdatePollRequest()); + + return result.map((response) => response.toModel()); + } + + /// Sets the fields in [set] and removes the fields named in [unset] on the + /// poll with the id [pollId]. + Future> partialUpdatePoll( + String pollId, { + Map? set, + List? unset, + }) async { + final result = await _api.updatePollPartial( + pollId: pollId, + updatePollPartialRequest: api.UpdatePollPartialRequest(set: set, unset: unset), + ); + + return result.map((response) => response.toModel()); + } + + /// Deletes the poll with the id [pollId]. + Future> deletePoll(String pollId) async { + final result = await _api.deletePoll(pollId: pollId); + + return result.ignoreValue(); + } + + /// Adds [option] to the poll with the id [pollId]. + Future> createPollOption(String pollId, PollOption option) async { + final result = await _api.createPollOption( + pollId: pollId, + createPollOptionRequest: option.toCreatePollOptionRequest(), + ); + + return result.map((response) => response.toModel()); + } + + /// Fetches an option of a poll, identified by [optionId] and [pollId]. + Future> getPollOption(String pollId, String optionId) async { + final result = await _api.getPollOption(pollId: pollId, optionId: optionId); + + return result.map((response) => response.toModel()); + } + + /// Replaces the text and custom data of [option] in the poll with the id + /// [pollId]. + /// + /// The [option] is matched by its id. + Future> updatePollOption(String pollId, PollOption option) async { + final result = await _api.updatePollOption( + pollId: pollId, + updatePollOptionRequest: option.toUpdatePollOptionRequest(), + ); + + return result.map((response) => response.toModel()); + } + + /// Removes an option from a poll, identified by [optionId] and [pollId]. + Future> deletePollOption(String pollId, String optionId) async { + final result = await _api.deletePollOption(pollId: pollId, optionId: optionId); + + return result.ignoreValue(); + } + + /// Casts a vote for an option of a poll sent in a message, identified by + /// [optionId], [pollId] and [messageId]. + Future> castPollVote( + String messageId, + String pollId, { + required String optionId, + }) => _castPollVote(messageId, pollId, api.VoteData(optionId: optionId)); + + /// Adds [answerText] as an answer to a poll sent in a message, identified by + /// [pollId] and [messageId]. + Future> addPollAnswer( + String messageId, + String pollId, { + required String answerText, + }) => _castPollVote(messageId, pollId, api.VoteData(answerText: answerText)); + + Future> _castPollVote(String messageId, String pollId, api.VoteData vote) async { + final result = await _api.castPollVote( + messageId: messageId, + pollId: pollId, + castPollVoteRequest: api.CastPollVoteRequest(vote: vote), + ); + + return result.map((response) => response.toModel()); + } + + /// Removes a vote or answer from a poll sent in a message, identified by + /// [voteId], [pollId] and [messageId]. + Future> removePollVote(String messageId, String pollId, String voteId) async { + final result = await _api.deletePollVote(messageId: messageId, pollId: pollId, voteId: voteId); + + return result.map((response) => response.toModel()); + } + + /// Fetches one page of the polls matching [filter], ordered by [sort]. + /// + /// [next] and [prev] are the cursors a previous page returned; at most one of + /// them may be given. + Future> queryPolls({ + PollFilter? filter, + List? sort, + int? limit, + String? next, + String? prev, + }) async { + final result = await _api.queryPolls( + queryPollsRequest: api.QueryPollsRequest( + filter: filter?.toRequest(), + sort: sort?.map((it) => it.toRequest()).toList(), + limit: limit, + next: next, + prev: prev, + ), + ); + + return result.map((response) => response.toModel()); + } + + /// Fetches one page of the votes and answers matching [filter] on the poll + /// with the id [pollId], ordered by [sort]. + /// + /// [next] and [prev] are the cursors a previous page returned; at most one of + /// them may be given. + Future> queryPollVotes( + String pollId, { + PollVoteFilter? filter, + List? sort, + int? limit, + String? next, + String? prev, + }) async { + final result = await _api.queryPollVotes( + pollId: pollId, + queryPollVotesRequest: api.QueryPollVotesRequest( + filter: filter?.toRequest(), + sort: sort?.map((it) => it.toRequest()).toList(), + limit: limit, + next: next, + prev: prev, + ), + ); + + return result.map((response) => response.toModel()); + } +} diff --git a/packages/stream_chat/lib/src/ws/events/event.dart b/packages/stream_chat/lib/src/ws/events/event.dart index e20d1fc247..d58edba04e 100644 --- a/packages/stream_chat/lib/src/ws/events/event.dart +++ b/packages/stream_chat/lib/src/ws/events/event.dart @@ -1,6 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import '../../../stream_chat.dart'; +import '../../core/models/converters/v1_json_converters.dart'; import '../../core/util/serializer.dart'; part 'event.g.dart'; @@ -103,9 +104,11 @@ class Event extends WsEvent { final Message? message; /// The poll sent with the event + @PollV1JsonConverter() final Poll? poll; /// The poll vote sent with the event + @PollVoteV1JsonConverter() final PollVote? pollVote; /// The channel sent with the event diff --git a/packages/stream_chat/lib/src/ws/events/event.g.dart b/packages/stream_chat/lib/src/ws/events/event.g.dart index bdc619305b..600b7d6bc3 100644 --- a/packages/stream_chat/lib/src/ws/events/event.g.dart +++ b/packages/stream_chat/lib/src/ws/events/event.g.dart @@ -15,8 +15,11 @@ Event _$EventFromJson(Map json) => Event( me: json['me'] == null ? null : OwnUser.fromJson(json['me'] as Map), user: json['user'] == null ? null : User.fromJson(json['user'] as Map), message: json['message'] == null ? null : Message.fromJson(json['message'] as Map), - poll: json['poll'] == null ? null : Poll.fromJson(json['poll'] as Map), - pollVote: json['poll_vote'] == null ? null : PollVote.fromJson(json['poll_vote'] as Map), + poll: _$JsonConverterFromJson, Poll>(json['poll'], const PollV1JsonConverter().fromJson), + pollVote: _$JsonConverterFromJson, PollVote>( + json['poll_vote'], + const PollVoteV1JsonConverter().fromJson, + ), totalUnreadCount: (json['total_unread_count'] as num?)?.toInt(), unreadChannels: (json['unread_channels'] as num?)?.toInt(), reaction: json['reaction'] == null ? null : Reaction.fromJson(json['reaction'] as Map), @@ -48,14 +51,10 @@ Event _$EventFromJson(Map json) => Event( reminder: json['reminder'] == null ? null : MessageReminder.fromJson(json['reminder'] as Map), pushPreference: json['push_preference'] == null ? null - : PushPreference.fromJson( - json['push_preference'] as Map, - ), + : PushPreference.fromJson(json['push_preference'] as Map), channelPushPreference: json['channel_push_preference'] == null ? null - : ChannelPushPreference.fromJson( - json['channel_push_preference'] as Map, - ), + : ChannelPushPreference.fromJson(json['channel_push_preference'] as Map), channelMemberCount: (json['channel_member_count'] as num?)?.toInt(), channelMessageCount: (json['channel_message_count'] as num?)?.toInt(), watcherCount: (json['watcher_count'] as num?)?.toInt(), @@ -77,8 +76,11 @@ Map _$EventToJson(Event instance) => { 'me': ?instance.me?.toJson(), 'user': ?instance.user?.toJson(), 'message': ?instance.message?.toJson(), - 'poll': ?instance.poll?.toJson(), - 'poll_vote': ?instance.pollVote?.toJson(), + 'poll': ?_$JsonConverterToJson, Poll>(instance.poll, const PollV1JsonConverter().toJson), + 'poll_vote': ?_$JsonConverterToJson, PollVote>( + instance.pollVote, + const PollVoteV1JsonConverter().toJson, + ), 'channel': ?instance.channel?.toJson(), 'member': ?instance.member?.toJson(), 'reaction': ?instance.reaction?.toJson(), @@ -110,6 +112,9 @@ Map _$EventToJson(Event instance) => { 'extra_data': instance.extraData, }; +Value? _$JsonConverterFromJson(Object? json, Value? Function(Json json) fromJson) => + json == null ? null : fromJson(json as Json); + const _$AITypingStateEnumMap = { AITypingState.idle: 'AI_STATE_IDLE', AITypingState.error: 'AI_STATE_ERROR', @@ -117,3 +122,6 @@ const _$AITypingStateEnumMap = { AITypingState.thinking: 'AI_STATE_THINKING', AITypingState.generating: 'AI_STATE_GENERATING', }; + +Json? _$JsonConverterToJson(Value? value, Json? Function(Value value) toJson) => + value == null ? null : toJson(value); diff --git a/packages/stream_chat/lib/stream_chat.dart b/packages/stream_chat/lib/stream_chat.dart index a4877847c5..9b2d92fe95 100644 --- a/packages/stream_chat/lib/stream_chat.dart +++ b/packages/stream_chat/lib/stream_chat.dart @@ -129,6 +129,11 @@ export 'src/core/models/response/list_devices_response.dart'; export 'src/core/models/response/list_user_groups_response.dart'; export 'src/core/models/response/mute_users_response.dart'; export 'src/core/models/response/og_attachment_response.dart'; +export 'src/core/models/response/poll_option_response.dart'; +export 'src/core/models/response/poll_response.dart'; +export 'src/core/models/response/poll_vote_response.dart'; +export 'src/core/models/response/query_poll_votes_response.dart'; +export 'src/core/models/response/query_polls_response.dart'; export 'src/core/models/response/remove_user_group_members_response.dart'; export 'src/core/models/response/search_roles_response.dart'; export 'src/core/models/response/search_user_groups_response.dart'; @@ -144,6 +149,7 @@ export 'src/core/models/user.dart'; export 'src/core/models/user_block.dart'; export 'src/core/models/user_group.dart'; export 'src/core/models/user_group_member.dart'; +export 'src/core/models/voting_visibility.dart'; export 'src/core/util/extension.dart'; export 'src/core/util/message_rules.dart'; export 'src/db/chat_persistence_client.dart'; diff --git a/packages/stream_chat/test/fixtures/poll.json b/packages/stream_chat/test/fixtures/poll.json deleted file mode 100644 index 6dd7f46ded..0000000000 --- a/packages/stream_chat/test/fixtures/poll.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "id": "7fd88eb3-fc05-4e89-89af-36c6d8995dda", - "name": "test", - "description": "", - "voting_visibility": "public", - "enforce_unique_vote": false, - "max_votes_allowed": null, - "allow_user_suggested_options": false, - "allow_answers": false, - "vote_count": 0, - "options": [ - { - "id": "option1", - "text": "option1 text" - } - ], - "answers_count": 0, - "vote_counts_by_option": null, - "latest_votes_by_option": {}, - "own_votes": [ - { - "created_at": "2022-02-03T15:47:10.148169Z", - "id": "luke_skywalker", - "option_id": "option1", - "poll_id": "7fd88eb3-fc05-4e89-89af-36c6d8995dda", - "updated_at": "2024-03-18T16:44:45.749718Z" - } - ], - "created_by_id": "luke_skywalker", - "created_by": { - "id": "luke_skywalker", - "role": "user", - "created_at": "2022-02-03T15:47:10.148169Z", - "updated_at": "2024-03-18T16:44:45.749718Z", - "last_active": "2024-04-17T14:46:22.723731831Z", - "banned": false, - "online": true, - "name": "Luke Skywalker", - "image": "https://vignette.wikia.nocookie.net/starwars/images/2/20/LukeTLJ.jpg", - "birthland": "Tatooine" - }, - "created_at": "2024-04-17T14:46:23.001349Z", - "updated_at": "2024-04-17T14:46:23.001349Z" -} \ No newline at end of file diff --git a/packages/stream_chat/test/src/client/channel/channel_polls_test.dart b/packages/stream_chat/test/src/client/channel/channel_polls_test.dart new file mode 100644 index 0000000000..60bba74ee6 --- /dev/null +++ b/packages/stream_chat/test/src/client/channel/channel_polls_test.dart @@ -0,0 +1,334 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../../fakes.dart'; +import '../../mocks.dart'; +import '../../utils.dart'; + +const _channelId = 'test-channel-id'; +const _channelType = 'test-channel-type'; + +void main() { + test('Channel.sendPoll creates the poll and sends it in a message', () async { + final client = _client(); + final channel = _initializedChannel(client); + final createdPoll = _poll.copyWith(id: 'created-poll-id'); + when(() => client.createPoll(_poll)).thenAnswer((_) async => Result.success(_pollResponse(createdPoll))); + when( + () => client.sendMessage(any(), _channelId, _channelType), + ).thenAnswer( + (invocation) async => SendMessageResponse()..message = invocation.positionalArguments.first as Message, + ); + + final result = await channel.sendPoll(_poll, messageText: 'Vote please'); + + final sent = verify(() => client.sendMessage(captureAny(), _channelId, _channelType)).captured.single as Message; + expect(sent.text, 'Vote please'); + expect(sent.pollId, 'created-poll-id'); + expect(sent.poll, createdPoll); + expect(result.getOrNull()?.message.id, sent.id); + }); + + test('Channel.sendPoll returns the failure to create the poll without sending a message', () async { + final client = _client(); + final channel = _initializedChannel(client); + const error = StreamClientException(message: 'boom'); + when(() => client.createPoll(_poll)).thenAnswer((_) async => const Result.failure(error)); + + final result = await channel.sendPoll(_poll); + + expect(result.exceptionOrNull(), error); + verifyNever(() => client.sendMessage(any(), any(), any())); + }); + + test('Channel.sendPoll returns the failure to send the message without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + final error = apiException(code: StreamErrorCode.notAllowed, statusCode: 403); + when(() => client.createPoll(_poll)).thenAnswer((_) async => Result.success(_pollResponse(_poll))); + when(() => client.sendMessage(any(), _channelId, _channelType)).thenThrow(error); + + final result = await channel.sendPoll(_poll); + + expect(result.exceptionOrNull(), error); + }); + + test('Channel.updatePoll returns the updated poll', () async { + final client = _client(); + final channel = _initializedChannel(client); + when(() => client.updatePoll(_poll)).thenAnswer((_) async => Result.success(_pollResponse(_poll))); + + final result = await channel.updatePoll(_poll); + + expect(result, Result.success(_pollResponse(_poll))); + }); + + test('Channel.deletePoll deletes the poll and returns a success', () async { + final client = _client(); + final channel = _initializedChannel(client); + when(() => client.deletePoll(_poll.id)).thenAnswer((_) async => const Result.success(null)); + + final result = await channel.deletePoll(_poll); + + expect(result, const Result.success(null)); + }); + + test('Channel.closePoll closes the poll and returns it', () async { + final client = _client(); + final channel = _initializedChannel(client); + final closed = _poll.copyWith(isClosed: true); + when(() => client.closePoll(_poll.id)).thenAnswer((_) async => Result.success(_pollResponse(closed))); + + final result = await channel.closePoll(_poll); + + expect(result, Result.success(_pollResponse(closed))); + }); + + test('Channel.createPollOption adds the option to the poll and returns it', () async { + final client = _client(); + final channel = _initializedChannel(client); + const option = PollOption(text: 'Sushi'); + const response = PollOptionResponse( + duration: '4.21ms', + pollOption: PollOption(id: 'sushi', text: 'Sushi'), + ); + when(() => client.createPollOption(_poll.id, option)).thenAnswer((_) async => const Result.success(response)); + + final result = await channel.createPollOption(_poll, option); + + expect(result, const Result.success(response)); + }); + + test('Channel.castPollVote votes for the option and returns the vote', () async { + final client = _client(); + final channel = _initializedChannel(client); + final response = PollVoteResponse(duration: '4.21ms', vote: _vote); + when( + () => client.castPollVote(_message.id, _poll.id, optionId: 'pizza'), + ).thenAnswer((_) async => Result.success(response)); + + final result = await channel.castPollVote(_message, _poll, const PollOption(id: 'pizza', text: 'Pizza')); + + expect(result, Result.success(response)); + }); + + test('Channel.castPollVote returns a failure without voting when the option has no id', () async { + final client = _client(); + final channel = _initializedChannel(client); + + final result = await channel.castPollVote(_message, _poll, const PollOption(text: 'Pizza')); + + expect(result.exceptionOrNull(), isA()); + verifyNever(() => client.castPollVote(any(), any(), optionId: any(named: 'optionId'))); + }); + + test('Channel.addPollAnswer leaves the answer and returns it', () async { + final client = _client(); + final channel = _initializedChannel(client); + final response = PollVoteResponse(duration: '4.21ms', vote: _vote); + when( + () => client.addPollAnswer(_message.id, _poll.id, answerText: 'Anything'), + ).thenAnswer((_) async => Result.success(response)); + + final result = await channel.addPollAnswer(_message, _poll, answerText: 'Anything'); + + expect(result, Result.success(response)); + }); + + test('Channel.removePollVote removes the vote and returns it', () async { + final client = _client(); + final channel = _initializedChannel(client); + final response = PollVoteResponse(duration: '4.21ms', vote: _vote); + when( + () => client.removePollVote(_message.id, _poll.id, 'vote-id'), + ).thenAnswer((_) async => Result.success(response)); + + final result = await channel.removePollVote(_message, _poll, _vote); + + expect(result, Result.success(response)); + }); + + test('Channel.removePollVote returns a failure without removing anything when the vote has no id', () async { + final client = _client(); + final channel = _initializedChannel(client); + + final result = await channel.removePollVote(_message, _poll, PollVote(optionId: 'pizza')); + + expect(result.exceptionOrNull(), isA()); + verifyNever(() => client.removePollVote(any(), any(), any())); + }); + + test('Channel.queryPollVotes forwards the query and returns the matching votes', () async { + final client = _client(); + final channel = _initializedChannel(client); + final filter = Filter.equal(PollVoteFilterField.isAnswer, true); + final sort = [PollVoteSort.desc(PollVoteSortField.createdAt)]; + final response = QueryPollVotesResponse(duration: '4.21ms', votes: [_vote], next: 'after'); + when( + () => client.queryPollVotes(_poll.id, filter: filter, sort: sort, limit: 5, next: 'cursor'), + ).thenAnswer((_) async => Result.success(response)); + + final result = await channel.queryPollVotes(_poll.id, filter: filter, sort: sort, limit: 5, next: 'cursor'); + + expect(result, Result.success(response)); + }); + + test('Channel.queryPollVotes forwards the previous-page cursor and ten as the default limit', () async { + final client = _client(); + final channel = _initializedChannel(client); + const response = QueryPollVotesResponse(duration: '4.21ms', votes: []); + when( + () => client.queryPollVotes(_poll.id, limit: 10, prev: 'cursor'), + ).thenAnswer((_) async => const Result.success(response)); + + final result = await channel.queryPollVotes(_poll.id, prev: 'cursor'); + + expect(result, const Result.success(response)); + }); + + test('Channel.sendPoll reports an unexpected error sending the message as a client failure', () async { + final client = _client(); + final channel = _initializedChannel(client); + final thrown = StateError('unexpected'); + when(() => client.createPoll(_poll)).thenAnswer((_) async => Result.success(_pollResponse(_poll))); + when(() => client.sendMessage(any(), _channelId, _channelType)).thenThrow(thrown); + + final result = await channel.sendPoll(_poll); + + expect(result.exceptionOrNull(), isA().having((it) => it.cause, 'cause', thrown)); + }); + + test('Channel.updatePoll returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + when(() => client.updatePoll(_poll)).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.updatePoll(_poll); + + expect(result.exceptionOrNull(), _error); + }); + + test('Channel.deletePoll returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + when(() => client.deletePoll(_poll.id)).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.deletePoll(_poll); + + expect(result.exceptionOrNull(), _error); + }); + + test('Channel.closePoll returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + when(() => client.closePoll(_poll.id)).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.closePoll(_poll); + + expect(result.exceptionOrNull(), _error); + }); + + test('Channel.createPollOption returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + const option = PollOption(text: 'Sushi'); + when(() => client.createPollOption(_poll.id, option)).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.createPollOption(_poll, option); + + expect(result.exceptionOrNull(), _error); + }); + + test('Channel.castPollVote returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + when( + () => client.castPollVote(_message.id, _poll.id, optionId: 'pizza'), + ).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.castPollVote(_message, _poll, const PollOption(id: 'pizza', text: 'Pizza')); + + expect(result.exceptionOrNull(), _error); + }); + + test('Channel.addPollAnswer returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + when( + () => client.addPollAnswer(_message.id, _poll.id, answerText: 'Anything'), + ).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.addPollAnswer(_message, _poll, answerText: 'Anything'); + + expect(result.exceptionOrNull(), _error); + }); + + test('Channel.removePollVote returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + when( + () => client.removePollVote(_message.id, _poll.id, 'vote-id'), + ).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.removePollVote(_message, _poll, _vote); + + expect(result.exceptionOrNull(), _error); + }); + + test('Channel.queryPollVotes returns the failure without throwing', () async { + final client = _client(); + final channel = _initializedChannel(client); + when(() => client.queryPollVotes(_poll.id, limit: 10)).thenAnswer((_) async => const Result.failure(_error)); + + final result = await channel.queryPollVotes(_poll.id); + + expect(result.exceptionOrNull(), _error); + }); +} + +const _error = StreamClientException(message: 'boom'); + +final _poll = Poll( + id: 'poll-id', + name: 'Lunch?', + options: const [PollOption(id: 'pizza', text: 'Pizza')], + createdAt: DateTime.utc(2024), + updatedAt: DateTime.utc(2024), +); + +final _message = Message(id: 'message-id', pollId: 'poll-id'); + +final _vote = PollVote( + id: 'vote-id', + pollId: 'poll-id', + optionId: 'pizza', + createdAt: DateTime.utc(2024), + updatedAt: DateTime.utc(2024), +); + +PollResponse _pollResponse(Poll poll) => PollResponse(duration: '4.21ms', poll: poll); + +MockStreamChatClient _client() { + registerFallbackValue(FakeMessage()); + + final client = MockStreamChatClient(); + when(() => client.retryPolicy).thenReturn( + RetryPolicy(shouldRetry: (_, _, _) => false, delayFactor: Duration.zero), + ); + when(() => client.state).thenReturn(FakeClientState()); + when(() => client.moderation).thenReturn(MockModerationClient()); + when(() => client.channelDeliveryReporter.submitForDelivery(any())).thenAnswer((_) async {}); + return client; +} + +Channel _initializedChannel(StreamChatClient client) { + final channel = Channel.fromState( + client, + ChannelState( + channel: ChannelModel(id: _channelId, type: _channelType), + ), + ); + addTearDown(channel.dispose); + return channel; +} diff --git a/packages/stream_chat/test/src/client/client_polls_test.dart b/packages/stream_chat/test/src/client/client_polls_test.dart new file mode 100644 index 0000000000..db3f1add98 --- /dev/null +++ b/packages/stream_chat/test/src/client/client_polls_test.dart @@ -0,0 +1,642 @@ +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +import '../mocks.dart'; +import 'poll_fixtures.dart'; + +void main() { + late MockDefaultApi defaultApi; + late StreamChatClient client; + + setUpAll(() { + registerFallbackValue(const api.CreatePollRequest(name: 'fallback')); + registerFallbackValue(const api.UpdatePollRequest(id: 'fallback', name: 'fallback')); + registerFallbackValue(const api.CreatePollOptionRequest(text: 'fallback')); + registerFallbackValue(const api.UpdatePollOptionRequest(id: 'fallback', text: 'fallback')); + }); + + setUp(() { + defaultApi = MockDefaultApi(); + client = StreamChatClient('test-api-key', defaultApi: defaultApi); + }); + + test('StreamChatClient.createPoll sends the poll settings and returns the created poll', () async { + when( + () => defaultApi.createPoll(createPollRequest: any(named: 'createPollRequest')), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.createPoll(_newPoll()); + + final request = verify( + () => defaultApi.createPoll(createPollRequest: captureAny(named: 'createPollRequest')), + ).captured.single; + expect( + request, + const api.CreatePollRequest( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: [ + api.PollOptionInput(text: 'Pizza', custom: {'color': 'red', 'text': 'Pasta'}), + api.PollOptionInput(text: 'Sushi', custom: {}), + ], + votingVisibility: api.CreatePollRequestVotingVisibility.anonymous, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + isClosed: false, + custom: {'topic': 'food', 'name': 'Dinner?'}, + ), + ); + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.createPoll returns the failure without throwing', () async { + when( + () => defaultApi.createPoll(createPollRequest: any(named: 'createPollRequest')), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.createPoll(_newPoll()); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.getPoll sends the poll id and returns the poll', () async { + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.getPoll('poll-id'); + + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.getPoll returns the failure without throwing', () async { + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.getPoll('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test( + "StreamChatClient.getPoll keeps a custom field named like one of the poll's own fields", + () async { + final response = api.PollResponse( + duration: '4.21ms', + poll: generatedPoll.copyWith(custom: const {'topic': 'food', 'name': 'custom-name', 'own_votes': 'custom'}), + ); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPoll('poll-id'); + + expect(result.getOrNull()?.poll.custom, const { + 'topic': 'food', + 'name': 'custom-name', + 'own_votes': 'custom', + }); + }, + ); + + test('StreamChatClient.getPoll keeps a voting visibility the SDK does not name', () async { + final response = api.PollResponse( + duration: '4.21ms', + poll: generatedPoll.copyWith(votingVisibility: api.PollResponseDataVotingVisibility.fromJson('members_only')), + ); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPoll('poll-id'); + + expect(result.getOrNull()?.poll.votingVisibility, const VotingVisibility('members_only')); + }); + + test("StreamChatClient.getPoll reads someone else's vote in an anonymous poll without a user", () async { + final response = api.PollResponse( + duration: '4.21ms', + poll: generatedPoll.copyWith( + latestVotesByOption: { + 'pizza': [generatedVote.copyWith(userId: null, user: null)], + }, + ), + ); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPoll('poll-id'); + + final pizzaVote = result.getOrNull()?.poll.latestVotesByOption['pizza']?.single; + expect((pizzaVote?.userId, pizzaVote?.user), (null, null)); + }); + + test('StreamChatClient.getPoll reads a poll that does not say whether it is closed as open', () async { + final response = api.PollResponse(duration: '4.21ms', poll: generatedPoll.copyWith(isClosed: null)); + when(() => defaultApi.getPoll(pollId: 'poll-id')).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPoll('poll-id'); + + expect(result.getOrNull()?.poll.isClosed, isFalse); + }); + + test('StreamChatClient.updatePoll sends the poll settings and options and returns the updated poll', () async { + when( + () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.updatePoll( + _newPoll().copyWith( + options: const [ + PollOption(id: 'pizza', text: 'Pizza', custom: {'color': 'red', 'id': 'pasta'}), + PollOption(id: 'sushi', text: 'Sushi'), + ], + votingVisibility: const VotingVisibility('members_only'), + isClosed: true, + ), + ); + + final request = verify( + () => defaultApi.updatePoll(updatePollRequest: captureAny(named: 'updatePollRequest')), + ).captured.single; + expect( + request, + api.UpdatePollRequest( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: const [ + api.PollOptionRequest(id: 'pizza', text: 'Pizza', custom: {'color': 'red', 'id': 'pasta'}), + api.PollOptionRequest(id: 'sushi', text: 'Sushi', custom: {}), + ], + votingVisibility: api.UpdatePollRequestVotingVisibility.fromJson('members_only'), + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + isClosed: true, + custom: const {'topic': 'food', 'name': 'Dinner?'}, + ), + ); + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.updatePoll sends an option without an id with an empty id', () async { + when( + () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + await client.updatePoll(_newPoll().copyWith(options: const [PollOption(text: 'Pizza')])); + + final request = + verify( + () => defaultApi.updatePoll(updatePollRequest: captureAny(named: 'updatePollRequest')), + ).captured.single + as api.UpdatePollRequest; + expect(request.options, const [api.PollOptionRequest(id: '', text: 'Pizza', custom: {})]); + }); + + test('StreamChatClient.updatePoll returns the failure without throwing', () async { + when( + () => defaultApi.updatePoll(updatePollRequest: any(named: 'updatePollRequest')), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.updatePoll(poll); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.partialUpdatePoll sends the fields to set and unset and returns the updated poll', () async { + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest( + set: {'name': 'Dinner?'}, + unset: ['description'], + ), + ), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.partialUpdatePoll('poll-id', set: {'name': 'Dinner?'}, unset: ['description']); + + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.partialUpdatePoll returns the failure without throwing', () async { + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'name': 'Dinner?'}), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.partialUpdatePoll('poll-id', set: {'name': 'Dinner?'}); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.closePoll marks the poll closed and returns the closed poll', () async { + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'is_closed': true}), + ), + ).thenAnswer((_) async => Result.success(generatedPollResponse)); + + final result = await client.closePoll('poll-id'); + + expect(result, Result.success(pollResponse)); + }); + + test('StreamChatClient.closePoll returns the failure without throwing', () async { + when( + () => defaultApi.updatePollPartial( + pollId: 'poll-id', + updatePollPartialRequest: const api.UpdatePollPartialRequest(set: {'is_closed': true}), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.closePoll('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.deletePoll sends the poll id and returns a success', () async { + when( + () => defaultApi.deletePoll(pollId: 'poll-id'), + ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '4.21ms'))); + + final result = await client.deletePoll('poll-id'); + + expect(result, const Result.success(null)); + }); + + test('StreamChatClient.deletePoll returns the failure without throwing', () async { + when(() => defaultApi.deletePoll(pollId: 'poll-id')).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.deletePoll('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.queryPolls sends the filter, sort and cursor and returns the matching polls', () async { + when( + () => defaultApi.queryPolls( + queryPollsRequest: const api.QueryPollsRequest( + filter: { + 'is_closed': {r'$eq': true}, + }, + sort: [api.SortParamRequest(field: 'created_at', direction: -1)], + limit: 10, + next: 'next-cursor', + ), + ), + ).thenAnswer( + (_) async => Result.success( + api.QueryPollsResponse(duration: '4.21ms', polls: [generatedPoll], next: 'after', prev: 'before'), + ), + ); + + final result = await client.queryPolls( + filter: Filter.equal(PollFilterField.isClosed, true), + sort: [PollSort.desc(PollSortField.createdAt)], + limit: 10, + next: 'next-cursor', + ); + + expect( + result, + Result.success(QueryPollsResponse(duration: '4.21ms', polls: [poll], next: 'after', prev: 'before')), + ); + }); + + test('StreamChatClient.queryPolls sends the previous-page cursor and ten as the default limit', () async { + when( + () => defaultApi.queryPolls(queryPollsRequest: const api.QueryPollsRequest(limit: 10, prev: 'prev-cursor')), + ).thenAnswer((_) async => const Result.success(api.QueryPollsResponse(duration: '4.21ms', polls: []))); + + final result = await client.queryPolls(prev: 'prev-cursor'); + + expect(result, const Result.success(QueryPollsResponse(duration: '4.21ms', polls: []))); + }); + + test('StreamChatClient.queryPolls returns the failure without throwing', () async { + when( + () => defaultApi.queryPolls(queryPollsRequest: const api.QueryPollsRequest(limit: 10)), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.queryPolls(); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test( + 'StreamChatClient.createPollOption sends the option text and custom data and returns the created option', + () async { + when( + () => defaultApi.createPollOption( + pollId: 'poll-id', + createPollOptionRequest: const api.CreatePollOptionRequest( + text: 'Pizza', + custom: {'color': 'red', 'text': 'Pasta'}, + ), + ), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + + final result = await client.createPollOption( + 'poll-id', + const PollOption(text: 'Pizza', custom: {'color': 'red', 'text': 'Pasta'}), + ); + + expect(result, const Result.success(_pizzaResponse)); + }, + ); + + test('StreamChatClient.createPollOption returns the failure without throwing', () async { + when( + () => defaultApi.createPollOption( + pollId: 'poll-id', + createPollOptionRequest: any(named: 'createPollOptionRequest'), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.createPollOption('poll-id', const PollOption(text: 'Pizza')); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.getPollOption sends the poll and option ids and returns the option', () async { + when( + () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + + final result = await client.getPollOption('poll-id', 'pizza'); + + expect(result, const Result.success(_pizzaResponse)); + }); + + test('StreamChatClient.getPollOption returns the failure without throwing', () async { + when( + () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.getPollOption('poll-id', 'pizza'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test( + "StreamChatClient.getPollOption keeps a custom field named like one of the option's own fields", + () async { + final response = api.PollOptionResponse( + duration: '4.21ms', + pollOption: generatedPizza.copyWith(custom: const {'color': 'red', 'text': 'custom-text'}), + ); + when( + () => defaultApi.getPollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => Result.success(response)); + + final result = await client.getPollOption('poll-id', 'pizza'); + + expect(result.getOrNull()?.pollOption.custom, const {'color': 'red', 'text': 'custom-text'}); + }, + ); + + test('StreamChatClient.updatePollOption sends the option and returns the updated option', () async { + when( + () => defaultApi.updatePollOption( + pollId: 'poll-id', + updatePollOptionRequest: const api.UpdatePollOptionRequest( + id: 'pizza', + text: 'Pizza', + custom: {'color': 'red'}, + ), + ), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + + final result = await client.updatePollOption('poll-id', pizza); + + expect(result, const Result.success(_pizzaResponse)); + }); + + test('StreamChatClient.updatePollOption sends an option without an id with an empty id', () async { + when( + () => defaultApi.updatePollOption( + pollId: 'poll-id', + updatePollOptionRequest: const api.UpdatePollOptionRequest(id: '', text: 'Pizza', custom: {}), + ), + ).thenAnswer((_) async => const Result.success(_generatedPizzaResponse)); + + final result = await client.updatePollOption('poll-id', const PollOption(text: 'Pizza')); + + expect(result, const Result.success(_pizzaResponse)); + }); + + test('StreamChatClient.updatePollOption returns the failure without throwing', () async { + when( + () => defaultApi.updatePollOption( + pollId: 'poll-id', + updatePollOptionRequest: any(named: 'updatePollOptionRequest'), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.updatePollOption('poll-id', pizza); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.deletePollOption sends the poll and option ids and returns a success', () async { + when( + () => defaultApi.deletePollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.success(api.DurationResponse(duration: '4.21ms'))); + + final result = await client.deletePollOption('poll-id', 'pizza'); + + expect(result, const Result.success(null)); + }); + + test('StreamChatClient.deletePollOption returns the failure without throwing', () async { + when( + () => defaultApi.deletePollOption(pollId: 'poll-id', optionId: 'pizza'), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.deletePollOption('poll-id', 'pizza'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.castPollVote sends the selected option and returns the cast vote', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), + ), + ).thenAnswer( + (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedVote)), + ); + + final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); + + expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: vote))); + }); + + test('StreamChatClient.castPollVote returns the failure without throwing', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.castPollVote returns no vote when the response carries none', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(optionId: 'pizza')), + ), + ).thenAnswer((_) async => const Result.success(api.PollVoteResponse(duration: '4.21ms'))); + + final result = await client.castPollVote('message-id', 'poll-id', optionId: 'pizza'); + + expect(result, const Result.success(PollVoteResponse(duration: '4.21ms'))); + }); + + test('StreamChatClient.addPollAnswer sends the answer text and returns the answer', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(answerText: 'Anything')), + ), + ).thenAnswer( + (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedAnswer)), + ); + + final result = await client.addPollAnswer('message-id', 'poll-id', answerText: 'Anything'); + + expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: answer))); + }); + + test('StreamChatClient.addPollAnswer returns the failure without throwing', () async { + when( + () => defaultApi.castPollVote( + messageId: 'message-id', + pollId: 'poll-id', + castPollVoteRequest: const api.CastPollVoteRequest(vote: api.VoteData(answerText: 'Anything')), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.addPollAnswer('message-id', 'poll-id', answerText: 'Anything'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.removePollVote sends the vote id and returns the removed vote', () async { + when( + () => defaultApi.deletePollVote(messageId: 'message-id', pollId: 'poll-id', voteId: 'vote-id'), + ).thenAnswer( + (_) async => Result.success(api.PollVoteResponse(duration: '4.21ms', poll: generatedPoll, vote: generatedVote)), + ); + + final result = await client.removePollVote('message-id', 'poll-id', 'vote-id'); + + expect(result, Result.success(PollVoteResponse(duration: '4.21ms', vote: vote))); + }); + + test('StreamChatClient.removePollVote returns the failure without throwing', () async { + when( + () => defaultApi.deletePollVote(messageId: 'message-id', pollId: 'poll-id', voteId: 'vote-id'), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.removePollVote('message-id', 'poll-id', 'vote-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.queryPollVotes sends the filter, sort and cursor and returns the matching votes', () async { + when( + () => defaultApi.queryPollVotes( + pollId: 'poll-id', + queryPollVotesRequest: const api.QueryPollVotesRequest( + filter: { + 'is_answer': {r'$eq': true}, + }, + sort: [api.SortParamRequest(field: 'created_at', direction: 1)], + limit: 25, + next: 'next-cursor', + ), + ), + ).thenAnswer( + (_) async => Result.success( + api.PollVotesResponse(duration: '4.21ms', votes: [generatedVote, generatedAnswer], next: 'after', prev: 'x'), + ), + ); + + final result = await client.queryPollVotes( + 'poll-id', + filter: Filter.equal(PollVoteFilterField.isAnswer, true), + sort: [PollVoteSort.asc(PollVoteSortField.createdAt)], + limit: 25, + next: 'next-cursor', + ); + + expect( + result, + Result.success(QueryPollVotesResponse(duration: '4.21ms', votes: [vote, answer], next: 'after', prev: 'x')), + ); + }); + + test('StreamChatClient.queryPollVotes returns the failure without throwing', () async { + when( + () => defaultApi.queryPollVotes( + pollId: 'poll-id', + queryPollVotesRequest: const api.QueryPollVotesRequest(limit: 10), + ), + ).thenAnswer((_) async => const Result.failure(pollsApiError)); + + final result = await client.queryPollVotes('poll-id'); + + expect(result.exceptionOrNull(), pollsApiError); + }); + + test('StreamChatClient.queryPollVotes sends the previous-page cursor and ten as the default limit', () async { + when( + () => defaultApi.queryPollVotes( + pollId: 'poll-id', + queryPollVotesRequest: const api.QueryPollVotesRequest(limit: 10, prev: 'prev-cursor'), + ), + ).thenAnswer((_) async => const Result.success(api.PollVotesResponse(duration: '4.21ms', votes: []))); + + final result = await client.queryPollVotes('poll-id', prev: 'prev-cursor'); + + expect(result, const Result.success(QueryPollVotesResponse(duration: '4.21ms', votes: []))); + }); +} + +// A poll as a caller builds it: options without ids and the vote summary at its defaults. +Poll _newPoll() => Poll( + id: 'poll-id', + name: 'Lunch?', + description: 'Pick one', + options: const [ + PollOption(text: 'Pizza', custom: {'color': 'red', 'text': 'Pasta'}), + PollOption(text: 'Sushi'), + ], + votingVisibility: VotingVisibility.anonymous, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + voteCount: 7, + // Custom data named like one of the poll's own fields is sent as custom data. + custom: const {'topic': 'food', 'name': 'Dinner?'}, +); + +const _generatedPizzaResponse = api.PollOptionResponse(duration: '4.21ms', pollOption: generatedPizza); + +const _pizzaResponse = PollOptionResponse(duration: '4.21ms', pollOption: pizza); diff --git a/packages/stream_chat/test/src/client/client_test.dart b/packages/stream_chat/test/src/client/client_test.dart index c4a0a6ce7e..8233e17d88 100644 --- a/packages/stream_chat/test/src/client/client_test.dart +++ b/packages/stream_chat/test/src/client/client_test.dart @@ -3334,340 +3334,6 @@ void main() { verifyNoMoreInteractions(fakeChatApi.channel); }); - test('`.createPoll`', () async { - final poll = Poll( - name: 'What is your favorite color?', - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ); - - when(() => fakeChatApi.polls.createPoll(poll)).thenAnswer( - (_) async => CreatePollResponse()..poll = poll, - ); - - final res = await client.createPoll(poll); - expect(res, isNotNull); - expect(res.poll, poll); - - verify(() => fakeChatApi.polls.createPoll(poll)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.getPoll`', () async { - const pollId = 'test-poll-id'; - final poll = Poll( - id: pollId, - name: 'What is your favorite color?', - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ); - - when(() => fakeChatApi.polls.getPoll(pollId)).thenAnswer( - (_) async => GetPollResponse()..poll = poll, - ); - - final res = await client.getPoll(pollId); - expect(res, isNotNull); - expect(res.poll, poll); - - verify(() => fakeChatApi.polls.getPoll(pollId)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.updatePoll`', () async { - final poll = Poll( - id: 'test-poll-id', - name: 'What is your favorite color?', - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ); - - when(() => fakeChatApi.polls.updatePoll(poll)).thenAnswer( - (_) async => UpdatePollResponse()..poll = poll, - ); - - final res = await client.updatePoll(poll); - expect(res, isNotNull); - expect(res.poll, poll); - - verify(() => fakeChatApi.polls.updatePoll(poll)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.partialUpdatePoll`', () async { - const pollId = 'test-poll-id'; - final set = {'name': 'What is your favorite color?'}; - final unset = []; - - final poll = Poll( - id: pollId, - name: set['name']!, - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ); - - when( - () => fakeChatApi.polls.partialUpdatePoll(pollId, set: set, unset: unset), - ).thenAnswer((_) async => UpdatePollResponse()..poll = poll); - - final res = await client.partialUpdatePoll(pollId, set: set, unset: unset); - expect(res, isNotNull); - expect(res.poll.id, pollId); - expect(res.poll.name, set['name']); - - verify(() => fakeChatApi.polls.partialUpdatePoll(pollId, set: set, unset: unset)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.deletePoll`', () async { - const pollId = 'test-poll-id'; - - when(() => fakeChatApi.polls.deletePoll(pollId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.deletePoll(pollId); - expect(res, isNotNull); - - verify(() => fakeChatApi.polls.deletePoll(pollId)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.closePoll`', () async { - const pollId = 'test-poll-id'; - - when( - () => fakeChatApi.polls.partialUpdatePoll(pollId, set: {'is_closed': true}), - ).thenAnswer((_) async => UpdatePollResponse()); - - final res = await client.closePoll(pollId); - expect(res, isNotNull); - - verify(() => fakeChatApi.polls.partialUpdatePoll(pollId, set: {'is_closed': true})).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.createPollOption`', () async { - const pollId = 'test-poll-id'; - const option = PollOption(text: 'Red'); - - when( - () => fakeChatApi.polls.createPollOption(pollId, option), - ).thenAnswer((_) async => CreatePollOptionResponse()..pollOption = option); - - final res = await client.createPollOption(pollId, option); - expect(res, isNotNull); - expect(res.pollOption, option); - - verify(() => fakeChatApi.polls.createPollOption(pollId, option)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.getPollOption`', () async { - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - const option = PollOption(id: optionId, text: 'Red'); - - when( - () => fakeChatApi.polls.getPollOption(pollId, optionId), - ).thenAnswer((_) async => GetPollOptionResponse()..pollOption = option); - - final res = await client.getPollOption(pollId, optionId); - expect(res, isNotNull); - expect(res.pollOption, option); - - verify(() => fakeChatApi.polls.getPollOption(pollId, optionId)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.updatePollOption`', () async { - const pollId = 'test-poll-id'; - const option = PollOption(id: 'test-option-id', text: 'Red'); - - when( - () => fakeChatApi.polls.updatePollOption(pollId, option), - ).thenAnswer((_) async => UpdatePollOptionResponse()..pollOption = option); - - final res = await client.updatePollOption(pollId, option); - expect(res, isNotNull); - expect(res.pollOption, option); - - verify(() => fakeChatApi.polls.updatePollOption(pollId, option)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.deletePollOption`', () async { - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - - when(() => fakeChatApi.polls.deletePollOption(pollId, optionId)).thenAnswer((_) async => EmptyResponse()); - - final res = await client.deletePollOption(pollId, optionId); - expect(res, isNotNull); - - verify(() => fakeChatApi.polls.deletePollOption(pollId, optionId)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.castPollVote`', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - final vote = PollVote(optionId: optionId); - - // Custom matcher to check if the Vote object has the specified id - Matcher matchesVoteOption(String expected) => predicate( - (vote) => vote.optionId == expected, - 'Vote with option $expected', - ); - - when( - () => fakeChatApi.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId))), - ).thenAnswer((_) async => CastPollVoteResponse()..vote = vote); - - final res = await client.castPollVote(messageId, pollId, optionId: optionId); - expect(res, isNotNull); - expect(res.vote, vote); - - verify(() => fakeChatApi.polls.castPollVote(messageId, pollId, any(that: matchesVoteOption(optionId)))).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.addPollAnswer`', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - const answerText = 'Red'; - final vote = PollVote(answerText: answerText); - - // Custom matcher to check if the Vote object has the specified id - Matcher matchesVoteAnswer(String expected) => predicate( - (vote) => vote.answerText == expected, - 'Vote with answer $expected', - ); - - when( - () => fakeChatApi.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText))), - ).thenAnswer((_) async => CastPollVoteResponse()..vote = vote); - - final res = await client.addPollAnswer(messageId, pollId, answerText: answerText); - expect(res, isNotNull); - expect(res.vote, vote); - - verify( - () => fakeChatApi.polls.castPollVote(messageId, pollId, any(that: matchesVoteAnswer(answerText))), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.removePollVote`', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - const voteId = 'test-vote-id'; - - when( - () => fakeChatApi.polls.removePollVote(messageId, pollId, voteId), - ).thenAnswer((_) async => RemovePollVoteResponse()); - - final res = await client.removePollVote(messageId, pollId, voteId); - expect(res, isNotNull); - - verify(() => fakeChatApi.polls.removePollVote(messageId, pollId, voteId)).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.queryPolls`', () async { - final filter = PollFilter.in_(PollFilterField.id, const ['test-poll-id']); - final sort = [PollSort.desc(PollSortField.createdAt)]; - const pagination = PaginationParams(limit: 20); - - final polls = List.generate( - pagination.limit, - (index) => Poll( - id: 'test-poll-id-$index', - name: 'What is your favorite color?', - options: const [ - PollOption(text: 'Red'), - PollOption(text: 'Blue'), - ], - ), - ); - - when( - () => fakeChatApi.polls.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ), - ).thenAnswer( - (_) async => QueryPollsResponse()..polls = polls, - ); - - final res = await client.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ); - expect(res, isNotNull); - expect(res.polls.length, polls.length); - - verify( - () => fakeChatApi.polls.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - - test('`.queryPollVotes`', () async { - const pollId = 'test-poll-id'; - final filter = PollVoteFilter.in_(PollVoteFilterField.id, const ['test-vote-id']); - final sort = [PollVoteSort.desc(PollVoteSortField.createdAt)]; - const pagination = PaginationParams(limit: 20); - - final votes = List.generate( - pagination.limit, - (index) => PollVote(id: 'test-vote-id-$index', answerText: 'Red'), - ); - - when( - () => fakeChatApi.polls.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ), - ).thenAnswer( - (_) async => QueryPollVotesResponse()..votes = votes, - ); - - final res = await client.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ); - expect(res, isNotNull); - expect(res.votes.length, votes.length); - - verify( - () => fakeChatApi.polls.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ), - ).called(1); - verifyNoMoreInteractions(fakeChatApi.polls); - }); - test('`.updateUser`', () async { final user = User( id: 'test-user-id', diff --git a/packages/stream_chat/test/src/client/poll_fixtures.dart b/packages/stream_chat/test/src/client/poll_fixtures.dart new file mode 100644 index 0000000000..41ff94addf --- /dev/null +++ b/packages/stream_chat/test/src/client/poll_fixtures.dart @@ -0,0 +1,163 @@ +import 'package:stream_chat/open_api/api.dart' as api; +import 'package:stream_chat/stream_chat.dart'; + +// Fixtures shared by the poll client tests. Every generated object carries a value in every field, including the +// ones the SDK models leave out, so a field the mapping drops or swaps changes the compared object. + +/// A failure the generated API answers with. +const pollsApiError = StreamClientException(message: 'boom'); + +final generatedVoter = api.UserResponse( + avgResponseTime: 12, + banned: false, + blockedUserIds: const ['blocked-user'], + createdAt: DateTime.utc(2024, 1, 1), + custom: const {'favorite_color': 'blue'}, + deactivatedAt: DateTime.utc(2024, 1, 4), + deletedAt: DateTime.utc(2024, 1, 5), + id: 'luke', + image: 'https://example.com/luke.png', + language: 'en', + lastActive: DateTime.utc(2024, 1, 3), + name: 'Luke', + online: true, + revokeTokensIssuedBefore: DateTime.utc(2024, 1, 6), + role: 'user', + teams: const ['jedi'], + teamsRole: const {'jedi': 'admin'}, + updatedAt: DateTime.utc(2024, 1, 2), +); + +final voter = User( + id: 'luke', + role: 'user', + name: 'Luke', + image: 'https://example.com/luke.png', + createdAt: DateTime.utc(2024, 1, 1), + updatedAt: DateTime.utc(2024, 1, 2), + lastActive: DateTime.utc(2024, 1, 3), + online: true, + teams: const ['jedi'], + language: 'en', + teamsRole: const {'jedi': 'admin'}, + avgResponseTime: 12, + extraData: const {'favorite_color': 'blue'}, +); + +final generatedVote = api.PollVoteResponseData( + answerTextI18n: const {'language': 'en'}, + createdAt: DateTime.utc(2024, 4, 17, 10), + id: 'vote-id', + isAnswer: false, + optionId: 'pizza', + pollId: 'poll-id', + updatedAt: DateTime.utc(2024, 4, 17, 11), + user: generatedVoter, + userId: 'luke', +); + +final vote = PollVote( + id: 'vote-id', + pollId: 'poll-id', + optionId: 'pizza', + answerTextI18n: const {'language': 'en'}, + createdAt: DateTime.utc(2024, 4, 17, 10), + updatedAt: DateTime.utc(2024, 4, 17, 11), + userId: 'luke', + user: voter, +); + +// An answer selects no option, which arrives as an empty option id. +final generatedAnswer = api.PollVoteResponseData( + answerText: 'Anything', + answerTextI18n: const {'fr': "N'importe quoi"}, + createdAt: DateTime.utc(2024, 4, 18, 10), + id: 'answer-id', + isAnswer: true, + optionId: '', + pollId: 'poll-id', + updatedAt: DateTime.utc(2024, 4, 18, 11), + user: generatedVoter, + userId: 'luke', +); + +final answer = PollVote( + id: 'answer-id', + pollId: 'poll-id', + optionId: '', + answerText: 'Anything', + answerTextI18n: const {'fr': "N'importe quoi"}, + createdAt: DateTime.utc(2024, 4, 18, 10), + updatedAt: DateTime.utc(2024, 4, 18, 11), + userId: 'luke', + user: voter, +); + +const generatedPizza = api.PollOptionResponseData( + custom: {'color': 'red'}, + id: 'pizza', + text: 'Pizza', + textI18n: {'it': 'Pizza'}, +); + +const pizza = PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red'}); + +final generatedPoll = api.PollResponseData( + allowAnswers: true, + allowUserSuggestedOptions: true, + answersCount: 1, + createdAt: DateTime.utc(2024, 4, 17), + createdBy: generatedVoter, + createdById: 'luke', + custom: const {'topic': 'food'}, + description: 'Pick one', + descriptionI18n: const {'it': 'Scegline uno'}, + enforceUniqueVote: false, + id: 'poll-id', + isClosed: true, + latestAnswers: [generatedAnswer], + latestVotesByOption: { + 'pizza': [generatedVote], + }, + maxVotesAllowed: 2, + name: 'Lunch?', + nameI18n: const {'it': 'Pranzo?'}, + options: const [generatedPizza], + ownVotes: [generatedVote, generatedAnswer], + updatedAt: DateTime.utc(2024, 4, 18), + voteCount: 1, + voteCountsByOption: const {'pizza': 1}, + votingVisibility: api.PollResponseDataVotingVisibility.anonymous, +); + +final poll = Poll( + id: 'poll-id', + name: 'Lunch?', + nameI18n: const {'it': 'Pranzo?'}, + description: 'Pick one', + descriptionI18n: const {'it': 'Scegline uno'}, + options: const [pizza], + votingVisibility: VotingVisibility.anonymous, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + latestAnswers: [answer], + answersCount: 1, + allowUserSuggestedOptions: true, + isClosed: true, + createdAt: DateTime.utc(2024, 4, 17), + updatedAt: DateTime.utc(2024, 4, 18), + voteCountsByOption: const {'pizza': 1}, + voteCount: 1, + latestVotesByOption: { + 'pizza': [vote], + }, + createdById: 'luke', + createdBy: voter, + ownVotesAndAnswers: [vote, answer], + custom: const {'topic': 'food'}, +); + +final generatedPollResponse = api.PollResponse(duration: '4.21ms', poll: generatedPoll); + +final pollResponse = PollResponse(duration: '4.21ms', poll: poll); diff --git a/packages/stream_chat/test/src/core/api/polls_api_test.dart b/packages/stream_chat/test/src/core/api/polls_api_test.dart deleted file mode 100644 index 4b893bbef9..0000000000 --- a/packages/stream_chat/test/src/core/api/polls_api_test.dart +++ /dev/null @@ -1,495 +0,0 @@ -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:mocktail/mocktail.dart'; -import 'package:stream_chat/src/core/api/polls_api.dart'; -import 'package:stream_chat/src/core/api/requests.dart'; -import 'package:stream_chat/src/core/models/poll.dart'; -import 'package:stream_chat/src/core/models/poll_option.dart'; -import 'package:stream_chat/src/core/models/poll_vote.dart'; -import 'package:test/test.dart'; - -import '../../mocks.dart'; - -void main() { - Response successResponse(String path, {Object? data}) => Response( - data: data, - requestOptions: RequestOptions(path: path), - statusCode: 200, - ); - - late final client = MockHttpClient(); - late PollsApi pollsApi; - - setUp(() { - pollsApi = PollsApi(client); - }); - - test('createPoll', () async { - final poll = Poll( - id: 'test-poll-id', - name: 'test-poll-name', - options: const [ - PollOption( - id: 'test-option-id', - text: 'test-option-value', - ), - ], - ); - - const path = '/polls'; - - when( - () => client.post( - path, - data: jsonEncode(poll), - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'poll': poll.toJson(), - }, - ), - ); - - final res = await pollsApi.createPoll(poll); - - expect(res, isNotNull); - expect(res.poll.id, poll.id); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('getPoll', () async { - const pollId = 'test-poll-id'; - - const path = '/polls/$pollId'; - - final poll = Poll( - id: pollId, - name: 'test-poll-name', - options: const [ - PollOption( - id: 'test-option-id', - text: 'test-option-value', - ), - ], - ); - - when(() => client.get(path)).thenAnswer( - (_) async => successResponse( - path, - data: { - 'poll': poll.toJson(), - }, - ), - ); - - final res = await pollsApi.getPoll(pollId); - - expect(res, isNotNull); - expect(res.poll.id, pollId); - - verify(() => client.get(path)).called(1); - verifyNoMoreInteractions(client); - }); - - test('updatePoll', () async { - final poll = Poll( - id: 'test-poll-id', - name: 'test-poll-name', - options: const [ - PollOption( - id: 'test-option-id', - text: 'test-option-value', - ), - ], - ); - - const path = '/polls'; - - when( - () => client.put( - path, - data: jsonEncode(poll), - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'poll': poll.toJson(), - }, - ), - ); - - final res = await pollsApi.updatePoll(poll); - - expect(res, isNotNull); - expect(res.poll.id, poll.id); - - verify(() => client.put(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('partialUpdatePoll', () async { - const pollId = 'test-poll-id'; - const set = {'name': 'updated-poll-name'}; - const unset = ['key']; - - const path = '/polls/$pollId'; - final poll = Poll( - id: pollId, - name: set['name']!, - options: const [ - PollOption( - id: 'test-option-id', - text: 'test-option-value', - ), - ], - ); - - when( - () => client.patch( - path, - data: jsonEncode({'set': set, 'unset': unset}), - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'poll': poll.toJson(), - }, - ), - ); - - final res = await pollsApi.partialUpdatePoll( - pollId, - set: set, - unset: unset, - ); - - expect(res, isNotNull); - expect(res.poll.id, pollId); - expect(res.poll.name, set['name']); - - verify( - () => client.patch( - path, - data: jsonEncode({ - 'set': set, - 'unset': unset, - }), - ), - ).called(1); - verifyNoMoreInteractions(client); - }); - - test('deletePoll', () async { - const pollId = 'test-poll-id'; - - const path = '/polls/$pollId'; - - when( - () => client.delete(path), - ).thenAnswer((_) async => successResponse(path, data: {})); - - final res = await pollsApi.deletePoll(pollId); - - expect(res, isNotNull); - - verify(() => client.delete(path)).called(1); - verifyNoMoreInteractions(client); - }); - - test('createPollOption', () async { - const pollId = 'test-poll-id'; - const option = PollOption( - id: 'test-option-id', - text: 'test-option-value', - ); - - const path = '/polls/$pollId/options'; - - when( - () => client.post( - path, - data: jsonEncode(option), - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'poll_option': option.toJson() - ..addAll({ - 'id': option.id, - }), - }, - ), - ); - - final res = await pollsApi.createPollOption(pollId, option); - - expect(res, isNotNull); - expect(res.pollOption.id, option.id); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('getPollOption', () async { - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - - const path = '/polls/$pollId/options/$optionId'; - - const option = PollOption( - id: optionId, - text: 'test-option-value', - ); - - when(() => client.get(path)).thenAnswer( - (_) async => successResponse( - path, - data: { - 'poll_option': option.toJson() - ..addAll({ - 'id': option.id, - }), - }, - ), - ); - - final res = await pollsApi.getPollOption(pollId, optionId); - - expect(res, isNotNull); - expect(res.pollOption.id, optionId); - - verify(() => client.get(path)).called(1); - verifyNoMoreInteractions(client); - }); - - test('updatePollOption', () async { - const pollId = 'test-poll-id'; - const option = PollOption( - id: 'test-option-id', - text: 'test-option-value', - ); - - const path = '/polls/$pollId/options'; - - when( - () => client.put( - path, - data: jsonEncode(option), - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'poll_option': option.toJson() - ..addAll({ - 'id': option.id, - }), - }, - ), - ); - - final res = await pollsApi.updatePollOption(pollId, option); - - expect(res, isNotNull); - expect(res.pollOption.id, option.id); - - verify(() => client.put(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('deletePollOption', () async { - const pollId = 'test-poll-id'; - const optionId = 'test-option-id'; - - const path = '/polls/$pollId/options/$optionId'; - - when( - () => client.delete(path), - ).thenAnswer((_) async => successResponse(path, data: {})); - - final res = await pollsApi.deletePollOption(pollId, optionId); - - expect(res, isNotNull); - - verify(() => client.delete(path)).called(1); - verifyNoMoreInteractions(client); - }); - - test('castPollVote', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - final vote = PollVote( - pollId: 'test-poll-id', - optionId: 'test-option-id', - ); - - const path = '/messages/$messageId/polls/$pollId/vote'; - - when( - () => client.post( - path, - data: jsonEncode({ - 'vote': vote, - }), - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'vote': vote.toJson(), - }, - ), - ); - - final res = await pollsApi.castPollVote(messageId, pollId, vote); - - expect(res, isNotNull); - expect(res.vote.optionId, vote.optionId); - - verify(() => client.post(path, data: any(named: 'data'))).called(1); - verifyNoMoreInteractions(client); - }); - - test('removePollVote', () async { - const messageId = 'test-message-id'; - const pollId = 'test-poll-id'; - const voteId = 'test-vote-id'; - - const path = '/messages/$messageId/polls/$pollId/vote/$voteId'; - final vote = PollVote( - pollId: 'test-poll-id', - optionId: 'test-option-id', - ); - - when(() => client.delete(path)).thenAnswer( - (_) async => successResponse( - path, - data: { - 'vote': vote.toJson(), - }, - ), - ); - - final res = await pollsApi.removePollVote(messageId, pollId, voteId); - - expect(res, isNotNull); - - verify(() => client.delete(path)).called(1); - verifyNoMoreInteractions(client); - }); - - test('queryPolls', () async { - const path = '/polls/query'; - final filter = PollFilter.in_(PollFilterField.id, const ['p1', 'p2']); - final sort = [PollSort.desc(PollSortField.createdAt)]; - const pagination = PaginationParams(limit: 20); - - final payload = jsonEncode({ - 'filter': filter, - 'sort': sort, - ...pagination.toJson(), - }); - - final polls = List.generate( - pagination.limit, - (index) => Poll( - id: 'test-poll-id=$index', - name: 'test-poll-name=$index', - options: [ - PollOption( - id: 'test-option-id=$index', - text: 'test-option-value=$index', - ), - ], - ), - ); - - when( - () => client.post( - path, - data: payload, - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'polls': [...polls.map((it) => it.toJson())], - }, - ), - ); - - final res = await pollsApi.queryPolls( - filter: filter, - sort: sort, - pagination: pagination, - ); - - expect(res, isNotNull); - expect(res.polls, hasLength(polls.length)); - - verify( - () => client.post(path, data: any(named: 'data')), - ).called(1); - verifyNoMoreInteractions(client); - }); - - test('queryPollVotes', () async { - const pollId = 'test-poll-id'; - final filter = PollVoteFilter.in_(PollVoteFilterField.id, const ['v1', 'v2']); - final sort = [PollVoteSort.desc(PollVoteSortField.createdAt)]; - const pagination = PaginationParams(limit: 20); - - const path = '/polls/$pollId/votes'; - - final payload = jsonEncode({ - 'filter': filter, - 'sort': sort, - ...pagination.toJson(), - }); - - final votes = List.generate( - pagination.limit, - (index) => PollVote( - pollId: pollId, - optionId: 'test-option-id=$index', - ), - ); - - when( - () => client.post( - path, - data: payload, - ), - ).thenAnswer( - (_) async => successResponse( - path, - data: { - 'votes': [...votes.map((it) => it.toJson())], - }, - ), - ); - - final res = await pollsApi.queryPollVotes( - pollId, - filter: filter, - sort: sort, - pagination: pagination, - ); - - expect(res, isNotNull); - expect(res.votes, hasLength(votes.length)); - - verify( - () => client.post(path, data: any(named: 'data')), - ).called(1); - verifyNoMoreInteractions(client); - }); -} diff --git a/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart b/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart index 23ef98e53b..13e49ddba2 100644 --- a/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart +++ b/packages/stream_chat/test/src/core/api/stream_chat_api_test.dart @@ -23,10 +23,6 @@ void main() { expect(streamChatApi.message, isNotNull); }); - test('`.polls`', () { - expect(streamChatApi.polls, isNotNull); - }); - test('`.channel`', () { expect(streamChatApi.channel, isNotNull); }); diff --git a/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart b/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart new file mode 100644 index 0000000000..3283f0873e --- /dev/null +++ b/packages/stream_chat/test/src/core/models/converters/poll_v1_json_test.dart @@ -0,0 +1,316 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +void main() { + test('Message.fromJson reads every field of a poll', () { + final message = Message.fromJson({'id': 'message-id', 'poll': _fullPollJson()}); + + expect( + message.poll, + Poll( + id: 'poll-id', + name: 'Lunch?', + nameI18n: const {'it': 'Pranzo?'}, + description: 'Pick one', + descriptionI18n: const {'it': 'Scegline uno'}, + options: const [ + PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red'}), + PollOption(id: 'sushi', text: 'Sushi'), + ], + votingVisibility: VotingVisibility.public, + enforceUniqueVote: false, + maxVotesAllowed: 2, + allowAnswers: true, + allowUserSuggestedOptions: true, + isClosed: true, + voteCount: 1, + answersCount: 1, + voteCountsByOption: const {'pizza': 1}, + latestVotesByOption: { + 'pizza': [_vote], + }, + latestAnswers: [_answer], + ownVotesAndAnswers: [_vote, _answer], + createdById: 'luke', + createdBy: User.fromJson(const {'id': 'luke', 'name': 'Luke'}), + createdAt: DateTime.utc(2024, 4, 17, 14, 46, 23), + updatedAt: DateTime.utc(2024, 4, 18), + custom: const {'topic': 'food'}, + ), + ); + }); + + test('Message.fromJson reads poll dates sent as epoch nanoseconds', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'poll': { + 'id': 'poll-id', + 'name': 'Lunch?', + 'options': [], + 'created_at': 1704067200123456000, + 'updated_at': 1704153600000000000, + }, + }); + + expect(message.poll!.createdAt, DateTime.utc(2024, 1, 1, 0, 0, 0, 123, 456)); + expect(message.poll!.updatedAt, DateTime.utc(2024, 1, 2)); + }); + + test('Message.fromJson gives a poll without its optional keys the default settings', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'poll': {'id': 'poll-id', 'name': 'Lunch?', 'options': []}, + }); + + final poll = message.poll!; + expect(poll.votingVisibility, VotingVisibility.public); + expect(poll.enforceUniqueVote, isTrue); + expect(poll.maxVotesAllowed, isNull); + expect(poll.allowAnswers, isFalse); + expect(poll.allowUserSuggestedOptions, isFalse); + expect(poll.isClosed, isFalse); + expect(poll.voteCountsByOption, isEmpty); + expect(poll.latestVotesByOption, isEmpty); + expect(poll.ownVotesAndAnswers, isEmpty); + expect(poll.custom, isEmpty); + }); + + test('Message.fromJson keeps the translations of a poll out of its custom data', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'poll': { + 'id': 'poll-id', + 'name': 'Lunch?', + 'name_i18n': {'it': 'Pranzo?'}, + 'description_i18n': {'it': 'Scegline uno'}, + 'options': [], + 'topic': 'food', + }, + }); + + expect(message.poll!.custom, {'topic': 'food'}); + }); + + test('Message.fromJson keeps the translations of a poll option out of its custom data', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'poll': { + 'id': 'poll-id', + 'name': 'Lunch?', + 'options': [ + { + 'id': 'pizza', + 'text': 'Pizza', + 'text_i18n': {'it': 'Pizza'}, + 'color': 'red', + }, + ], + }, + }); + + expect(message.poll!.options.single.custom, {'color': 'red'}); + }); + + test('Message.fromJson keeps a voting visibility the SDK does not name', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'poll': {'id': 'poll-id', 'name': 'Lunch?', 'options': [], 'voting_visibility': 'members_only'}, + }); + + expect(message.poll!.votingVisibility, const VotingVisibility('members_only')); + }); + + test('Message.fromJson reads an answer, which selects no option', () { + final message = Message.fromJson(const { + 'id': 'message-id', + 'poll': { + 'id': 'poll-id', + 'name': 'Lunch?', + 'options': [], + 'latest_answers': [_answerJson], + }, + }); + + final answer = message.poll!.latestAnswers.single; + expect(answer.optionId, ''); + expect(answer.isAnswer, isTrue); + }); + + test('Event.fromJson reads the poll and the vote of a vote event', () { + final event = Event.fromJson({ + 'type': EventType.pollVoteCasted, + 'poll': _fullPollJson(), + 'poll_vote': _voteJson, + }); + + expect(event.poll!.id, 'poll-id'); + expect(event.poll!.ownVotesAndAnswers, [_vote, _answer]); + expect(event.pollVote, _vote); + }); + + test('Event.toJson writes the poll settings with the custom data beside them, without the translations', () { + final event = Event( + type: EventType.pollUpdated, + poll: Poll( + id: 'poll-id', + name: 'Lunch?', + nameI18n: const {'it': 'Pranzo?'}, + description: 'Pick one', + descriptionI18n: const {'it': 'Scegline uno'}, + options: const [ + PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red'}), + ], + voteCount: 3, + custom: const {'topic': 'food'}, + ), + ); + + expect(event.toJson()['poll'], { + 'topic': 'food', + 'id': 'poll-id', + 'name': 'Lunch?', + 'description': 'Pick one', + 'options': [ + {'color': 'red', 'id': 'pizza', 'text': 'Pizza'}, + ], + 'voting_visibility': 'public', + 'enforce_unique_vote': true, + 'max_votes_allowed': null, + 'allow_user_suggested_options': false, + 'allow_answers': false, + 'is_closed': false, + }); + }); + + test('Event.toJson leaves out a poll custom entry named like a poll key', () { + final event = Event( + type: EventType.pollUpdated, + poll: Poll(id: 'poll-id', name: 'Lunch?', options: const [], custom: const {'name': 'Dinner?'}), + ); + + final poll = event.toJson()['poll'] as Map; + + expect(poll['name'], 'Lunch?'); + }); + + test('Event.toJson leaves out an option custom entry named like an option key', () { + final event = Event( + type: EventType.pollUpdated, + poll: Poll( + id: 'poll-id', + name: 'Lunch?', + options: const [ + PollOption(id: 'pizza', text: 'Pizza', custom: {'id': 'pasta'}), + ], + ), + ); + + final poll = event.toJson()['poll'] as Map; + + expect(poll['options'], [ + {'id': 'pizza', 'text': 'Pizza'}, + ]); + }); + + test('Event.toJson writes only the id, the option and the answer text of a vote', () { + final event = Event(type: EventType.pollAnswerCasted, pollVote: _answer); + + expect(event.toJson()['poll_vote'], {'id': 'answer-id', 'option_id': '', 'answer_text': 'Anything'}); + }); + + test('Event.fromJson reads back the poll settings written by toJson', () { + final poll = Poll( + id: 'poll-id', + name: 'Lunch?', + options: const [ + PollOption(id: 'pizza', text: 'Pizza', custom: {'color': 'red'}), + ], + votingVisibility: VotingVisibility.anonymous, + maxVotesAllowed: 2, + custom: const {'topic': 'food'}, + ); + + final event = Event.fromJson(Event(type: EventType.pollUpdated, poll: poll).toJson()); + + expect(event.poll!.copyWith(createdAt: poll.createdAt, updatedAt: poll.updatedAt), poll); + }); +} + +final _vote = PollVote( + id: 'vote-id', + pollId: 'poll-id', + optionId: 'pizza', + createdAt: DateTime.utc(2024, 4, 17), + updatedAt: DateTime.utc(2024, 4, 17), + userId: 'luke', + user: User.fromJson(const {'id': 'luke', 'name': 'Luke'}), +); + +const _voteJson = { + 'id': 'vote-id', + 'poll_id': 'poll-id', + 'option_id': 'pizza', + 'created_at': '2024-04-17T00:00:00Z', + 'updated_at': '2024-04-17T00:00:00Z', + 'user_id': 'luke', + 'user': {'id': 'luke', 'name': 'Luke'}, +}; + +final _answer = PollVote( + id: 'answer-id', + pollId: 'poll-id', + optionId: '', + answerText: 'Anything', + answerTextI18n: const {'fr': "N'importe quoi"}, + createdAt: DateTime.utc(2024, 4, 18), + updatedAt: DateTime.utc(2024, 4, 18), + userId: 'luke', +); + +const _answerJson = { + 'id': 'answer-id', + 'poll_id': 'poll-id', + 'option_id': '', + 'answer_text': 'Anything', + 'answer_text_i18n': {'fr': "N'importe quoi"}, + 'is_answer': true, + 'created_at': '2024-04-18T00:00:00Z', + 'updated_at': '2024-04-18T00:00:00Z', + 'user_id': 'luke', +}; + +Map _fullPollJson() => { + 'id': 'poll-id', + 'name': 'Lunch?', + 'name_i18n': {'it': 'Pranzo?'}, + 'description': 'Pick one', + 'description_i18n': {'it': 'Scegline uno'}, + 'options': [ + { + 'id': 'pizza', + 'text': 'Pizza', + 'text_i18n': {'it': 'Pizza'}, + 'color': 'red', + }, + {'id': 'sushi', 'text': 'Sushi'}, + ], + 'voting_visibility': 'public', + 'enforce_unique_vote': false, + 'max_votes_allowed': 2, + 'allow_answers': true, + 'allow_user_suggested_options': true, + 'is_closed': true, + 'vote_count': 1, + 'answers_count': 1, + 'vote_counts_by_option': {'pizza': 1}, + 'latest_votes_by_option': { + 'pizza': [_voteJson], + }, + 'latest_answers': [_answerJson], + 'own_votes': [_voteJson, _answerJson], + 'created_by_id': 'luke', + 'created_by': {'id': 'luke', 'name': 'Luke'}, + 'created_at': '2024-04-17T14:46:23Z', + 'updated_at': '2024-04-18T00:00:00Z', + 'topic': 'food', +}; diff --git a/packages/stream_chat/test/src/core/models/poll_option_test.dart b/packages/stream_chat/test/src/core/models/poll_option_test.dart new file mode 100644 index 0000000000..33b4489b3a --- /dev/null +++ b/packages/stream_chat/test/src/core/models/poll_option_test.dart @@ -0,0 +1,45 @@ +import 'package:stream_chat/stream_chat.dart'; +import 'package:test/test.dart'; + +void main() { + test('PollOption.toData writes the id, the text, its translations and the custom data under their stored keys', () { + const option = PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red'}); + + expect(option.toData(), { + 'id': 'pizza', + 'text': 'Pizza', + 'text_i18n': {'it': 'Pizza'}, + 'custom': {'color': 'red'}, + }); + }); + + test('PollOption.toData leaves out an id and translations that are null', () { + const option = PollOption(text: 'Pizza'); + + expect(option.toData().keys, unorderedEquals(['text', 'custom'])); + }); + + test('PollOption.fromData reads back the option written by toData', () { + const option = PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}, custom: {'color': 'red'}); + + expect(PollOption.fromData(option.toData()), option); + }); + + test('PollOption compares its custom data for equality', () { + const option = PollOption(id: 'pizza', text: 'Pizza', custom: {'color': 'red'}); + + expect(option, isNot(option.copyWith(custom: const {'color': 'green'}))); + }); + + test('PollOption.copyWith clears an id passed as null', () { + const option = PollOption(id: 'pizza', text: 'Pizza'); + + expect(option.copyWith(id: null).id, isNull); + }); + + test('PollOption.copyWith clears the translations passed as null', () { + const option = PollOption(id: 'pizza', text: 'Pizza', textI18n: {'it': 'Pizza'}); + + expect(option.copyWith(textI18n: null).textI18n, isNull); + }); +} diff --git a/packages/stream_chat/test/src/core/models/poll_test.dart b/packages/stream_chat/test/src/core/models/poll_test.dart index 74cc23480c..f6d68c66b9 100644 --- a/packages/stream_chat/test/src/core/models/poll_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_test.dart @@ -1,210 +1,123 @@ -// ignore_for_file: avoid_redundant_argument_values - -import 'package:stream_chat/src/core/models/poll.dart'; -import 'package:stream_chat/src/core/models/poll_option.dart'; +import 'package:stream_chat/stream_chat.dart'; import 'package:test/test.dart'; import '../../utils.dart'; void main() { - group('src/models/message', () { - test('should parse json correctly', () { - final poll = Poll.fromJson(jsonFixture('poll.json')); - - expect(poll.id, '7fd88eb3-fc05-4e89-89af-36c6d8995dda'); - expect(poll.name, 'test'); - expect(poll.description, ''); - expect(poll.votingVisibility, VotingVisibility.public); - expect(poll.enforceUniqueVote, false); - expect(poll.maxVotesAllowed, isNull); - expect(poll.allowUserSuggestedOptions, false); - expect(poll.allowAnswers, false); - expect(poll.isClosed, false); - expect(poll.voteCount, 0); - expect(poll.answersCount, 0); - - expect(poll.createdAt.toIso8601String(), '2024-04-17T14:46:23.001349Z'); - expect(poll.updatedAt.toIso8601String(), '2024-04-17T14:46:23.001349Z'); - - expect(poll.options.length, 1); - final option = poll.options[0]; - expect(option.id, 'option1'); - expect(option.text, 'option1 text'); - - expect(poll.latestVotesByOption, isEmpty); - - expect(poll.ownVotesAndAnswers.length, 1); - final vote = poll.ownVotesAndAnswers[0]; - expect(vote.id, 'luke_skywalker'); - expect(vote.optionId, 'option1'); - expect(vote.pollId, '7fd88eb3-fc05-4e89-89af-36c6d8995dda'); - expect(vote.createdAt.toIso8601String(), '2022-02-03T15:47:10.148169Z'); - expect(vote.updatedAt.toIso8601String(), '2024-03-18T16:44:45.749718Z'); - - // Check createdBy fields - expect(poll.createdById, 'luke_skywalker'); - expect(poll.createdBy, isNotNull); - }); - - test('should serialize to json correctly', () { - final poll = Poll( - id: '7fd88eb3-fc05-4e89-89af-36c6d8995dda', - name: 'test', - options: const [ - PollOption( - text: 'option1 text', - ), - ], - ); - - final json = poll.toJson(); - - expect(json['id'], '7fd88eb3-fc05-4e89-89af-36c6d8995dda'); - expect(json['name'], 'test'); - expect(json['description'], isNull); - expect(json['options'], [ - {'text': 'option1 text'}, - ]); - expect(json['voting_visibility'], 'public'); - expect(json['enforce_unique_vote'], true); - expect(json['max_votes_allowed'], isNull); - expect(json['allow_user_suggested_options'], false); - expect(json['allow_answers'], false); - expect(json['is_closed'], false); - }); - - test('parses the server translations of the name and description', () { - final poll = Poll.fromJson({ - ...jsonFixture('poll.json'), - 'name_i18n': const {'language': 'en', 'nl_text': 'toets'}, - 'description_i18n': const {'language': 'en', 'nl_text': 'omschrijving'}, - }); - - expect(poll.nameI18n, {'language': 'en', 'nl_text': 'toets'}); - expect(poll.descriptionI18n, {'language': 'en', 'nl_text': 'omschrijving'}); - }); - - test('parses the server translation of an option', () { - final poll = Poll.fromJson({ - ...jsonFixture('poll.json'), - 'options': const [ - { - 'id': 'option1', - 'text': 'option1 text', - 'text_i18n': {'language': 'en', 'nl_text': 'optie1 tekst'}, - }, - ], - }); - - expect(poll.options.single.textI18n, {'language': 'en', 'nl_text': 'optie1 tekst'}); - }); - - test('parses the server translation of an answer', () { - final poll = Poll.fromJson({ - ...jsonFixture('poll.json'), - 'latest_answers': const [ - { - 'id': 'answer1', - 'answer_text': 'great', - 'answer_text_i18n': {'language': 'en', 'nl_text': 'geweldig'}, - }, - ], - }); - - expect(poll.latestAnswers.single.answerTextI18n, {'language': 'en', 'nl_text': 'geweldig'}); - }); - - test('keeps the server translations out of the extra data', () { - final poll = Poll.fromJson({ - ...jsonFixture('poll.json'), - 'name_i18n': const {'language': 'en', 'nl_text': 'toets'}, - 'description_i18n': const {'language': 'en', 'nl_text': 'omschrijving'}, - }); - - expect(poll.extraData, isNot(contains('name_i18n'))); - expect(poll.extraData, isNot(contains('description_i18n'))); - }); - - test('does not send the server translations back when serialized', () { - final poll = Poll( - name: 'test', - nameI18n: const {'language': 'en', 'nl_text': 'toets'}, - descriptionI18n: const {'language': 'en', 'nl_text': 'omschrijving'}, - options: const [ - PollOption(text: 'option1 text', textI18n: {'language': 'en', 'nl_text': 'optie1 tekst'}), - ], - ); - - final json = poll.toJson(); - - expect(json, isNot(contains('name_i18n'))); - expect(json, isNot(contains('description_i18n'))); - }); - - test('keeps the server translation of an option out of its extra data', () { - final option = PollOption.fromJson(const { - 'id': 'option1', - 'text': 'option1 text', - 'text_i18n': {'language': 'en', 'nl_text': 'optie1 tekst'}, - }); - - expect(option.extraData, isNot(contains('text_i18n'))); - }); - - test('PollOption.toJson leaves out the server translation', () { - const option = PollOption( - id: 'option1', - text: 'option1 text', - textI18n: {'language': 'en', 'nl_text': 'optie1 tekst'}, - ); - - expect(option.toJson(), isNot(contains('text_i18n'))); - }); - - group('PollSortField', () { - test('id orders alphabetically', () { - expectOrders( - PollSortField.id, - createTestPoll(id: 'a-poll', name: 'A'), - createTestPoll(id: 'b-poll', name: 'B'), - ); - }); - - test('name orders alphabetically, folded', () { - expectOrders( - PollSortField.name, - createTestPoll(name: 'apples'), - createTestPoll(name: 'Bananas'), - ); - }); - - test('createdAt orders older polls first', () { - expectOrders( - PollSortField.createdAt, - createTestPoll(name: 'older', createdAt: DateTime(2023, 6, 10)), - createTestPoll(name: 'newer', createdAt: DateTime(2023, 6, 15)), - ); - }); - - test('updatedAt orders older polls first', () { - expectOrders( - PollSortField.updatedAt, - createTestPoll(name: 'older', updatedAt: DateTime(2023, 6, 10)), - createTestPoll(name: 'newer', updatedAt: DateTime(2023, 6, 15)), - ); - }); - - test('isClosed orders open polls first', () { - expectOrders( - PollSortField.isClosed, - createTestPoll(name: 'open', isClosed: false), - createTestPoll(name: 'closed', isClosed: true), - ); - }); - }); + test('Poll generates an id when none is given', () { + final first = Poll(name: 'Lunch?', options: const []); + final second = Poll(name: 'Lunch?', options: const []); + + expect(first.id, isNotEmpty); + expect(first.id, isNot(second.id)); + }); + + test('Poll compares its custom data for equality', () { + final poll = createTestPoll(id: 'poll-id', name: 'Lunch?', custom: const {'topic': 'food'}); + + expect(poll, poll.copyWith(custom: const {'topic': 'food'})); + expect(poll, isNot(poll.copyWith(custom: const {'topic': 'drinks'}))); + }); + + test('Poll.copyWith can clear the vote limit', () { + final poll = createTestPoll(name: 'Lunch?', maxVotesAllowed: 2); + + expect(poll.copyWith(maxVotesAllowed: null).maxVotesAllowed, isNull); + }); + + test('Poll.copyWith clears a nullable field passed as null', () { + final poll = createTestPoll(name: 'Lunch?', description: 'Pick one').copyWith(createdBy: User(id: 'user-id')); + + final copy = poll.copyWith(description: null, createdBy: null); + + expect(copy.description, isNull); + expect(copy.createdBy, isNull); + }); + + test('Poll.latestVotes holds the latest votes of every option', () { + final pizza = _vote(id: 'v1', optionId: 'pizza'); + final sushi = _vote(id: 'v2', optionId: 'sushi'); + final poll = createTestPoll(name: 'Lunch?').copyWith( + latestVotesByOption: { + 'pizza': [pizza], + 'sushi': [sushi], + }, + ); + + expect(poll.latestVotes, unorderedEquals([pizza, sushi])); + }); + + test('Poll.ownVotes leaves out the answers of the current user', () { + final vote = _vote(id: 'v1', optionId: 'pizza'); + final answer = _vote(id: 'a1', answerText: 'Anything'); + final poll = createTestPoll(name: 'Lunch?').copyWith(ownVotesAndAnswers: [vote, answer]); + + expect(poll.ownVotes, [vote]); + }); + + test('Poll.ownAnswers leaves out the votes of the current user', () { + final vote = _vote(id: 'v1', optionId: 'pizza'); + final answer = _vote(id: 'a1', answerText: 'Anything'); + final poll = createTestPoll(name: 'Lunch?').copyWith(ownVotesAndAnswers: [vote, answer]); + + expect(poll.ownAnswers, [answer]); + }); + + test('PollFilterField.votingVisibility reads the visibility value', () { + final poll = createTestPoll(name: 'Lunch?', votingVisibility: VotingVisibility.anonymous); + + expect(PollFilterField.votingVisibility.value(poll), 'anonymous'); + }); + + test('PollSortField.id orders alphabetically', () { + expectOrders( + PollSortField.id, + createTestPoll(id: 'a-poll', name: 'A'), + createTestPoll(id: 'b-poll', name: 'B'), + ); + }); + + test('PollSortField.name orders alphabetically, folded', () { + expectOrders( + PollSortField.name, + createTestPoll(name: 'apples'), + createTestPoll(name: 'Bananas'), + ); + }); + + test('PollSortField.createdAt orders older polls first', () { + expectOrders( + PollSortField.createdAt, + createTestPoll(name: 'older', createdAt: DateTime(2023, 6, 10)), + createTestPoll(name: 'newer', createdAt: DateTime(2023, 6, 15)), + ); + }); + + test('PollSortField.updatedAt orders older polls first', () { + expectOrders( + PollSortField.updatedAt, + createTestPoll(name: 'older', updatedAt: DateTime(2023, 6, 10)), + createTestPoll(name: 'newer', updatedAt: DateTime(2023, 6, 15)), + ); + }); + + test('PollSortField.isClosed orders open polls first', () { + expectOrders( + PollSortField.isClosed, + createTestPoll(name: 'open'), + createTestPoll(name: 'closed', isClosed: true), + ); }); } +PollVote _vote({required String id, String? optionId, String? answerText}) => PollVote( + id: id, + optionId: optionId, + answerText: answerText, + createdAt: DateTime.utc(2024), + updatedAt: DateTime.utc(2024), +); + /// Helper function to create a Poll for testing Poll createTestPoll({ String? id, @@ -219,7 +132,7 @@ Poll createTestPoll({ bool isClosed = false, DateTime? createdAt, DateTime? updatedAt, - Map? extraData, + Map? custom, }) { return Poll( id: id, @@ -234,6 +147,6 @@ Poll createTestPoll({ isClosed: isClosed, createdAt: createdAt ?? DateTime(2023), updatedAt: updatedAt ?? DateTime(2023), - extraData: extraData ?? {}, + custom: custom ?? {}, ); } diff --git a/packages/stream_chat/test/src/core/models/poll_vote_test.dart b/packages/stream_chat/test/src/core/models/poll_vote_test.dart index 7cac30ea2d..fd8cdeb27b 100644 --- a/packages/stream_chat/test/src/core/models/poll_vote_test.dart +++ b/packages/stream_chat/test/src/core/models/poll_vote_test.dart @@ -5,41 +5,54 @@ import 'package:test/test.dart'; import '../../utils.dart'; void main() { - group('src/models/poll_vote', () { - test('does not send the server translation of the answer back when serialized', () { - final pollVote = PollVote( - answerText: 'great', - answerTextI18n: const {'language': 'en', 'nl_text': 'geweldig'}, - ); + test('PollVote.isAnswer is true for a vote that carries answer text', () { + final answer = createTestPollVote(answerText: 'Anything'); - expect(pollVote.toJson(), isNot(contains('answer_text_i18n'))); - }); + expect(answer.isAnswer, isTrue); + }); + + test('PollVote.isAnswer is false for a vote for an option', () { + final vote = createTestPollVote(optionId: 'pizza'); + + expect(vote.isAnswer, isFalse); + }); + + test('PollVote.copyWith clears a nullable field passed as null', () { + final vote = createTestPollVote( + id: 'vote-id', + optionId: 'pizza', + userId: 'user-id', + user: User(id: 'user-id'), + ); - group('PollVoteSortField', () { - test('id orders alphabetically', () { - expectOrders( - PollVoteSortField.id, - createTestPollVote(id: 'a-vote', optionId: 'o'), - createTestPollVote(id: 'b-vote', optionId: 'o'), - ); - }); + final copy = vote.copyWith(userId: null, user: null); - test('createdAt orders older votes first', () { - expectOrders( - PollVoteSortField.createdAt, - createTestPollVote(optionId: 'o', createdAt: DateTime(2023, 6, 10)), - createTestPollVote(optionId: 'o', createdAt: DateTime(2023, 6, 15)), - ); - }); + expect(copy.userId, isNull); + expect(copy.user, isNull); + }); + + test('PollVoteSortField.id orders alphabetically', () { + expectOrders( + PollVoteSortField.id, + createTestPollVote(id: 'a-vote', optionId: 'o'), + createTestPollVote(id: 'b-vote', optionId: 'o'), + ); + }); + + test('PollVoteSortField.createdAt orders older votes first', () { + expectOrders( + PollVoteSortField.createdAt, + createTestPollVote(optionId: 'o', createdAt: DateTime(2023, 6, 10)), + createTestPollVote(optionId: 'o', createdAt: DateTime(2023, 6, 15)), + ); + }); - test('updatedAt orders older votes first', () { - expectOrders( - PollVoteSortField.updatedAt, - createTestPollVote(optionId: 'o', updatedAt: DateTime(2023, 6, 10)), - createTestPollVote(optionId: 'o', updatedAt: DateTime(2023, 6, 15)), - ); - }); - }); + test('PollVoteSortField.updatedAt orders older votes first', () { + expectOrders( + PollVoteSortField.updatedAt, + createTestPollVote(optionId: 'o', updatedAt: DateTime(2023, 6, 10)), + createTestPollVote(optionId: 'o', updatedAt: DateTime(2023, 6, 15)), + ); }); } diff --git a/packages/stream_chat/test/src/fakes.dart b/packages/stream_chat/test/src/fakes.dart index 9021e9a1df..f7e0985475 100644 --- a/packages/stream_chat/test/src/fakes.dart +++ b/packages/stream_chat/test/src/fakes.dart @@ -6,7 +6,6 @@ import 'package:stream_chat/src/core/api/channel_api.dart'; import 'package:stream_chat/src/core/api/general_api.dart'; import 'package:stream_chat/src/core/api/message_api.dart'; import 'package:stream_chat/src/core/api/moderation_api.dart'; -import 'package:stream_chat/src/core/api/polls_api.dart'; import 'package:stream_chat/src/core/api/push_preferences_api.dart'; import 'package:stream_chat/src/core/api/user_api.dart'; import 'package:stream_chat/stream_chat.dart'; @@ -134,11 +133,6 @@ class FakeChatApi extends Fake implements StreamChatApi { @override MessageApi get message => _message ??= MockMessageApi(); - @override - PollsApi get polls => _polls ??= MockPollsApi(); - - PollsApi? _polls; - ChannelApi? _channel; @override diff --git a/packages/stream_chat/test/src/mocks.dart b/packages/stream_chat/test/src/mocks.dart index 004e7d7d64..862672b853 100644 --- a/packages/stream_chat/test/src/mocks.dart +++ b/packages/stream_chat/test/src/mocks.dart @@ -10,7 +10,6 @@ import 'package:stream_chat/src/core/api/channel_api.dart'; import 'package:stream_chat/src/core/api/general_api.dart'; import 'package:stream_chat/src/core/api/message_api.dart'; import 'package:stream_chat/src/core/api/moderation_api.dart'; -import 'package:stream_chat/src/core/api/polls_api.dart'; import 'package:stream_chat/src/core/api/push_preferences_api.dart'; import 'package:stream_chat/src/core/api/user_api.dart'; import 'package:stream_chat/src/core/http/stream_http_client.dart'; @@ -46,8 +45,6 @@ class MockUserApi extends Mock implements UserApi {} class MockMessageApi extends Mock implements MessageApi {} -class MockPollsApi extends Mock implements PollsApi {} - class MockChannelApi extends Mock implements ChannelApi {} class MockPushPreferencesApi extends Mock implements PushPreferencesApi {} diff --git a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart index 1925aa251b..0ffbdc188d 100644 --- a/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart +++ b/packages/stream_chat_flutter/lib/src/channel/stream_message_preview_text.dart @@ -91,10 +91,8 @@ class StreamMessagePreviewText extends StatelessWidget { @internal Message translateForPreview(Message message, String? language) { final translated = message.translate(language); - return switch (message.poll) { - final poll? when poll.translatedName(language) != null => translated.copyWith( - poll: poll.copyWith(name: poll.translatedName(language)), - ), + return switch ((message.poll, message.poll?.translatedName(language))) { + (final poll?, final name?) => translated.copyWith(poll: poll.copyWith(name: name)), _ => translated, }; } diff --git a/packages/stream_chat_flutter/test/src/message_widget/components/stream_message_content_poll_translation_test.dart b/packages/stream_chat_flutter/test/src/message_widget/components/stream_message_content_poll_translation_test.dart index 5a88b08977..834c49d294 100644 --- a/packages/stream_chat_flutter/test/src/message_widget/components/stream_message_content_poll_translation_test.dart +++ b/packages/stream_chat_flutter/test/src/message_widget/components/stream_message_content_poll_translation_test.dart @@ -307,22 +307,28 @@ MockChannel _mockChannel({List? comments}) { any(), filter: any(named: 'filter'), sort: any(named: 'sort'), - pagination: any(named: 'pagination'), + limit: any(named: 'limit'), + next: any(named: 'next'), ), ).thenAnswer( - (_) async => QueryPollVotesResponse() - ..votes = - comments ?? - [ - PollVote( - id: 'answer-1', - answerText: 'I like yellow', - answerTextI18n: const {'language': 'en', 'nl_text': 'Ik hou van geel'}, - ), - ] - ..next = null, + (_) async => Result.success( + QueryPollVotesResponse( + duration: '4.21ms', + votes: + comments ?? + [ + PollVote( + id: 'answer-1', + answerText: 'I like yellow', + answerTextI18n: const {'language': 'en', 'nl_text': 'Ik hou van geel'}, + ), + ], + ), + ), ); - when(() => channel.castPollVote(any(), any(), any())).thenAnswer((_) async => CastPollVoteResponse()); + when( + () => channel.castPollVote(any(), any(), any()), + ).thenAnswer((_) async => const Result.success(PollVoteResponse(duration: '4.21ms'))); return channel; } diff --git a/packages/stream_chat_flutter_core/CHANGELOG.md b/packages/stream_chat_flutter_core/CHANGELOG.md index f6194a6455..af3403b6f9 100644 --- a/packages/stream_chat_flutter_core/CHANGELOG.md +++ b/packages/stream_chat_flutter_core/CHANGELOG.md @@ -8,6 +8,7 @@ - The sort argument on every list controller is typed `List`, `List` and so on, rather than `SortOrder`. On the controllers that have a default, the `sort` property is non-nullable: leaving it unset selects that default, and `ChannelSort.empty` / `MemberSort.empty` / … query with the ordering the API applies on its own. - `defaultChannelListSort`, `defaultMemberListSort`, `defaultUserListSort`, `defaultDraftListSort`, `defaultMessageReminderListSort` and `defaultPollVoteListSort` are removed. Use `ChannelSort.defaultSort`, `MemberSort.defaultSort` and so on from `stream_chat`. Leaving a controller's sort unset still applies the same ordering. - `StreamChannelListController.muteChannel` and `unmuteChannel` return a `Result` instead of throwing. +- `StreamPollController.addOption` and `updateOption` take the option's custom data as `custom` instead of `extraData`. 🐞 Fixed diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart index 216ae0d3f0..65ae886e9a 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_controller.dart @@ -177,29 +177,28 @@ class StreamPollController extends ValueNotifier { return invalidErrors; } - /// Adds a new option with the provided [text] and [extraData]. + /// Adds an option with [text] and the custom data [custom]. /// /// The new option will be added to the end of the list of options. void addOption( String text, { - Map extraData = const {}, + Map custom = const {}, }) { final options = [...value.options]; - final newOption = PollOption(text: text, extraData: extraData); + final newOption = PollOption(text: text, custom: custom); value = value.copyWith(options: [...options, newOption]); } - /// Updates the option at the provided [index] with the provided [text] and - /// [extraData]. + /// Updates the option at [index] to [text] and the custom data [custom]. void updateOption( String text, { required int index, - Map extraData = const {}, + Map custom = const {}, }) { final options = [...value.options]; options[index] = options[index].copyWith( text: text, - extraData: extraData, + custom: custom, ); value = value.copyWith(options: options); diff --git a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart index 27800c8d8c..5e752c6ee8 100644 --- a/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart +++ b/packages/stream_chat_flutter_core/lib/src/stream_poll_vote_list_controller.dart @@ -113,61 +113,57 @@ class StreamPollVoteListController extends PagedValueNotifier this.limit * defaultInitialPagedLimitMultiplier, _kDefaultBackendPaginationLimit, ); - try { - final response = await channel.queryPollVotes( - pollId, - sort: _activeSort, - filter: _activeFilter, - pagination: PaginationParams(limit: limit), - ); - - final results = response.votes; - final nextKey = response.next; - value = PagedValue( - items: results, - nextPageKey: nextKey, - ); - - // start listening to events - if (disposed) return; - _subscribeToPollVoteEvents(); - } on StreamChatException catch (error) { - value = PagedValue.error(error); - } catch (error) { - final chatError = StreamClientException(message: 'Failed to load poll votes', cause: error); - value = PagedValue.error(chatError); - } + final result = await _queryPollVotes(limit: limit); + + result.fold( + onSuccess: (response) { + value = PagedValue( + items: response.votes, + nextPageKey: response.next, + ); + + if (disposed) return; + _subscribeToPollVoteEvents(); + }, + onFailure: (error, _) => value = PagedValue.error(_chatException(error, 'Failed to load poll votes')), + ); } @override Future loadMore(String nextPageKey) async { final previousValue = value.asSuccess; + final result = await _queryPollVotes(limit: limit, next: nextPageKey); + + result.fold( + onSuccess: (response) { + final next = response.next; + value = PagedValue( + items: previousValue.items + response.votes, + nextPageKey: next != null && next.isNotEmpty ? next : null, + ); + }, + onFailure: (error, _) { + value = previousValue.copyWith(error: _chatException(error, 'Failed to load more poll votes')); + }, + ); + } + + // Turns anything the channel throws, such as on a channel that is not + // initialized, into a failure. + Future> _queryPollVotes({required int limit, String? next}) async { try { - final response = await channel.queryPollVotes( - pollId, - sort: _activeSort, - filter: _activeFilter, - pagination: PaginationParams(limit: limit, next: nextPageKey), - ); - - final results = response.votes; - final previousItems = previousValue.items; - final newItems = previousItems + results; - final next = response.next; - final nextKey = next != null && next.isNotEmpty ? next : null; - value = PagedValue( - items: newItems, - nextPageKey: nextKey, - ); - } on StreamChatException catch (error) { - value = previousValue.copyWith(error: error); - } catch (error) { - final chatError = StreamClientException(message: 'Failed to load more poll votes', cause: error); - value = previousValue.copyWith(error: chatError); + return await channel.queryPollVotes(pollId, sort: _activeSort, filter: _activeFilter, limit: limit, next: next); + } catch (error, stackTrace) { + return Result.failure(error, stackTrace); } } + StreamChatException _chatException(Object error, String message) => switch (error) { + final StreamChatException error => error, + _ => StreamClientException(message: message, cause: error), + }; + @override Future refresh({bool resetValue = true}) { if (resetValue) { diff --git a/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart index 408b4c9c29..814fe8c83e 100644 --- a/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart +++ b/packages/stream_chat_flutter_core/test/stream_poll_controller_test.dart @@ -77,11 +77,11 @@ void main() { final pollController = StreamPollController() ..addOption( 'Option 1', - extraData: {'key': 'value'}, + custom: {'key': 'value'}, ); expect(pollController.value.options.length, 2); - expect(pollController.value.options.last.extraData['key'], 'value'); + expect(pollController.value.options.last.custom['key'], 'value'); }); test('Update Option', () { diff --git a/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart new file mode 100644 index 0000000000..e33b7b7e1c --- /dev/null +++ b/packages/stream_chat_flutter_core/test/stream_poll_vote_list_controller_test.dart @@ -0,0 +1,171 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart'; + +import 'mocks.dart'; + +void main() { + test('StreamPollVoteListController.doInitialLoad loads the first page of votes', () async { + final channel = _channelAnswering([ + Result.success(_page([_vote('v1'), _vote('v2')], next: 'cursor')), + ]); + final controller = _controller(channel); + + await controller.doInitialLoad(); + + expect(controller.value, PagedValue(items: [_vote('v1'), _vote('v2')], nextPageKey: 'cursor')); + }); + + test('StreamPollVoteListController.doInitialLoad asks for three pages at once', () async { + final channel = _channelAnswering([Result.success(_page([]))]); + final controller = _controller(channel); + + await controller.doInitialLoad(); + + verify( + () => channel.queryPollVotes( + 'poll-id', + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: 30, + ), + ); + }); + + test('StreamPollVoteListController.doInitialLoad turns a failure into the error state', () async { + const error = StreamClientException(message: 'boom'); + final channel = _channelAnswering([const Result.failure(error)]); + final controller = _controller(channel); + + await controller.doInitialLoad(); + + expect(controller.value, const PagedValue.error(error)); + }); + + test('StreamPollVoteListController.doInitialLoad turns an error the channel throws into the error state', () async { + final channel = MockChannel(); + final thrown = StateError('Channel is not initialized'); + when( + () => channel.queryPollVotes( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: any(named: 'limit'), + next: any(named: 'next'), + prev: any(named: 'prev'), + ), + ).thenThrow(thrown); + final controller = _controller(channel); + + await controller.doInitialLoad(); + + final error = controller.value.mapOrNull(null, error: (it) => it.error); + expect(error, isA().having((it) => it.cause, 'cause', thrown)); + }); + + test('StreamPollVoteListController.loadMore appends the next page of votes', () async { + final channel = _channelAnswering([ + Result.success(_page([_vote('v1')], next: 'cursor')), + Result.success(_page([_vote('v2')])), + ]); + final controller = _controller(channel); + await controller.doInitialLoad(); + + await controller.loadMore('cursor'); + + expect(controller.value, PagedValue(items: [_vote('v1'), _vote('v2')])); + }); + + test('StreamPollVoteListController.loadMore asks for a page of ten after the given cursor', () async { + final channel = _channelAnswering([ + Result.success(_page([_vote('v1')], next: 'cursor')), + Result.success(_page([])), + ]); + final controller = _controller(channel); + await controller.doInitialLoad(); + + await controller.loadMore('cursor'); + + verify( + () => channel.queryPollVotes( + 'poll-id', + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: 10, + next: 'cursor', + ), + ); + }); + + test('StreamPollVoteListController.loadMore keeps the loaded votes and records a failure', () async { + const error = StreamClientException(message: 'boom'); + final channel = _channelAnswering([ + Result.success(_page([_vote('v1')], next: 'cursor')), + const Result.failure(error), + ]); + final controller = _controller(channel); + await controller.doInitialLoad(); + + await controller.loadMore('cursor'); + + expect(controller.value, PagedValue(items: [_vote('v1')], nextPageKey: 'cursor', error: error)); + }); + + test('StreamPollVoteListController.loadMore records an error the channel throws', () async { + final channel = _channelAnswering([ + Result.success(_page([_vote('v1')], next: 'cursor')), + ]); + final controller = _controller(channel); + await controller.doInitialLoad(); + final thrown = StateError('Channel is not initialized'); + when( + () => channel.queryPollVotes( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: any(named: 'limit'), + next: any(named: 'next'), + prev: any(named: 'prev'), + ), + ).thenThrow(thrown); + + await controller.loadMore('cursor'); + + expect(controller.value.asSuccess.error, isA().having((it) => it.cause, 'cause', thrown)); + }); +} + +PollVote _vote(String id) => PollVote( + id: id, + pollId: 'poll-id', + optionId: 'pizza', + createdAt: DateTime.utc(2024), + updatedAt: DateTime.utc(2024), +); + +QueryPollVotesResponse _page(List votes, {String? next}) => + QueryPollVotesResponse(duration: '4.21ms', votes: votes, next: next); + +// A channel whose vote queries answer [results] in order. +MockChannel _channelAnswering(List> results) { + final channel = MockChannel(); + final pending = [...results]; + when( + () => channel.queryPollVotes( + any(), + filter: any(named: 'filter'), + sort: any(named: 'sort'), + limit: any(named: 'limit'), + next: any(named: 'next'), + prev: any(named: 'prev'), + ), + ).thenAnswer((_) async => pending.removeAt(0)); + when(channel.on).thenAnswer((_) => const Stream.empty()); + return channel; +} + +StreamPollVoteListController _controller(Channel channel) { + final controller = StreamPollVoteListController(channel: channel, pollId: 'poll-id'); + addTearDown(controller.dispose); + return controller; +} diff --git a/packages/stream_chat_persistence/CHANGELOG.md b/packages/stream_chat_persistence/CHANGELOG.md index 8d2e4e1b2e..99d6c23d3e 100644 --- a/packages/stream_chat_persistence/CHANGELOG.md +++ b/packages/stream_chat_persistence/CHANGELOG.md @@ -10,6 +10,7 @@ 🔄 Changed - Every persistence call logged its own name at `info`; that call trace is `verbose` now. +- A cached poll with a voting visibility the SDK does not name is read back instead of failing. 🐞 Fixed diff --git a/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart b/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart index 047be71a46..c33a94b221 100644 --- a/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart +++ b/packages/stream_chat_persistence/lib/src/converter/voting_visibility_converter.dart @@ -7,26 +7,8 @@ class VotingVisibilityConverter extends TypeConverter const VotingVisibilityConverter(); @override - VotingVisibility fromSql(String fromDb) { - for (final entry in _votingVisibilityEnumMap.entries) { - if (entry.value == fromDb) { - return entry.key; - } - } - - throw ArgumentError( - '`$fromDb` is not one of the supported values: ' - '${_votingVisibilityEnumMap.values.join(', ')}', - ); - } + VotingVisibility fromSql(String fromDb) => VotingVisibility(fromDb); @override - String toSql(VotingVisibility value) { - return _votingVisibilityEnumMap[value]!; - } + String toSql(VotingVisibility value) => value; } - -const _votingVisibilityEnumMap = { - VotingVisibility.anonymous: 'anonymous', - VotingVisibility.public: 'public', -}; diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart index b8241b86af..43ef0b607c 100644 --- a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart +++ b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.dart @@ -59,7 +59,7 @@ class DriftChatDatabase extends _$DriftChatDatabase { // Band the version by SDK major, and bump the offset whenever you change or // add a table definition. @override - int get schemaVersion => 1100 + 3; + int get schemaVersion => 1100 + 4; // Store DateTime as ISO-8601 text to preserve sub-second precision. @override diff --git a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart index f3449ea86d..1125f1cb31 100644 --- a/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart +++ b/packages/stream_chat_persistence/lib/src/db/drift_chat_database.g.dart @@ -6487,13 +6487,13 @@ class $PollsTable extends Polls with TableInfo<$PollsTable, PollEntity> { defaultValue: currentDateAndTime, ); @override - late final GeneratedColumnWithTypeConverter?, String> extraData = GeneratedColumn( - 'extra_data', + late final GeneratedColumnWithTypeConverter?, String> custom = GeneratedColumn( + 'custom', aliasedName, true, type: DriftSqlType.string, requiredDuringInsert: false, - ).withConverter?>($PollsTable.$converterextraDatan); + ).withConverter?>($PollsTable.$convertercustomn); @override List get $columns => [ id, @@ -6514,7 +6514,7 @@ class $PollsTable extends Polls with TableInfo<$PollsTable, PollEntity> { createdById, createdAt, updatedAt, - extraData, + custom, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -6719,10 +6719,10 @@ class $PollsTable extends Polls with TableInfo<$PollsTable, PollEntity> { DriftSqlType.dateTime, data['${effectivePrefix}updated_at'], )!, - extraData: $PollsTable.$converterextraDatan.fromSql( + custom: $PollsTable.$convertercustomn.fromSql( attachedDatabase.typeMapping.read( DriftSqlType.string, - data['${effectivePrefix}extra_data'], + data['${effectivePrefix}custom'], ), ), ); @@ -6738,9 +6738,9 @@ class $PollsTable extends Polls with TableInfo<$PollsTable, PollEntity> { static TypeConverter, String> $converteroptions = ListConverter(); static TypeConverter $convertervotingVisibility = const VotingVisibilityConverter(); static TypeConverter, String> $convertervoteCountsByOption = MapConverter(); - static TypeConverter, String> $converterextraData = MapConverter(); - static TypeConverter?, String?> $converterextraDatan = NullAwareTypeConverter.wrap( - $converterextraData, + static TypeConverter, String> $convertercustom = MapConverter(); + static TypeConverter?, String?> $convertercustomn = NullAwareTypeConverter.wrap( + $convertercustom, ); } @@ -6808,8 +6808,8 @@ class PollEntity extends DataClass implements Insertable { /// The date when the poll was last updated. final DateTime updatedAt; - /// Map of custom poll extraData - final Map? extraData; + /// Custom data attached to the poll. + final Map? custom; const PollEntity({ required this.id, required this.name, @@ -6829,7 +6829,7 @@ class PollEntity extends DataClass implements Insertable { this.createdById, required this.createdAt, required this.updatedAt, - this.extraData, + this.custom, }); @override Map toColumns(bool nullToAbsent) { @@ -6880,9 +6880,9 @@ class PollEntity extends DataClass implements Insertable { } map['created_at'] = Variable(createdAt); map['updated_at'] = Variable(updatedAt); - if (!nullToAbsent || extraData != null) { - map['extra_data'] = Variable( - $PollsTable.$converterextraDatan.toSql(extraData), + if (!nullToAbsent || custom != null) { + map['custom'] = Variable( + $PollsTable.$convertercustomn.toSql(custom), ); } return map; @@ -6920,7 +6920,7 @@ class PollEntity extends DataClass implements Insertable { createdById: serializer.fromJson(json['createdById']), createdAt: serializer.fromJson(json['createdAt']), updatedAt: serializer.fromJson(json['updatedAt']), - extraData: serializer.fromJson?>(json['extraData']), + custom: serializer.fromJson?>(json['custom']), ); } @override @@ -6951,7 +6951,7 @@ class PollEntity extends DataClass implements Insertable { 'createdById': serializer.toJson(createdById), 'createdAt': serializer.toJson(createdAt), 'updatedAt': serializer.toJson(updatedAt), - 'extraData': serializer.toJson?>(extraData), + 'custom': serializer.toJson?>(custom), }; } @@ -6974,7 +6974,7 @@ class PollEntity extends DataClass implements Insertable { Value createdById = const Value.absent(), DateTime? createdAt, DateTime? updatedAt, - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), }) => PollEntity( id: id ?? this.id, name: name ?? this.name, @@ -6994,7 +6994,7 @@ class PollEntity extends DataClass implements Insertable { createdById: createdById.present ? createdById.value : this.createdById, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, - extraData: extraData.present ? extraData.value : this.extraData, + custom: custom.present ? custom.value : this.custom, ); PollEntity copyWithCompanion(PollsCompanion data) { return PollEntity( @@ -7018,7 +7018,7 @@ class PollEntity extends DataClass implements Insertable { createdById: data.createdById.present ? data.createdById.value : this.createdById, createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, - extraData: data.extraData.present ? data.extraData.value : this.extraData, + custom: data.custom.present ? data.custom.value : this.custom, ); } @@ -7043,7 +7043,7 @@ class PollEntity extends DataClass implements Insertable { ..write('createdById: $createdById, ') ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt, ') - ..write('extraData: $extraData') + ..write('custom: $custom') ..write(')')) .toString(); } @@ -7068,7 +7068,7 @@ class PollEntity extends DataClass implements Insertable { createdById, createdAt, updatedAt, - extraData, + custom, ); @override bool operator ==(Object other) => @@ -7092,7 +7092,7 @@ class PollEntity extends DataClass implements Insertable { other.createdById == this.createdById && other.createdAt == this.createdAt && other.updatedAt == this.updatedAt && - other.extraData == this.extraData); + other.custom == this.custom); } class PollsCompanion extends UpdateCompanion { @@ -7114,7 +7114,7 @@ class PollsCompanion extends UpdateCompanion { final Value createdById; final Value createdAt; final Value updatedAt; - final Value?> extraData; + final Value?> custom; final Value rowid; const PollsCompanion({ this.id = const Value.absent(), @@ -7135,7 +7135,7 @@ class PollsCompanion extends UpdateCompanion { this.createdById = const Value.absent(), this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), - this.extraData = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }); PollsCompanion.insert({ @@ -7157,13 +7157,13 @@ class PollsCompanion extends UpdateCompanion { this.createdById = const Value.absent(), this.createdAt = const Value.absent(), this.updatedAt = const Value.absent(), - this.extraData = const Value.absent(), + this.custom = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), name = Value(name), options = Value(options), voteCountsByOption = Value(voteCountsByOption); - static Insertable custom({ + static Insertable createCustom({ Expression? id, Expression? name, Expression? nameI18n, @@ -7182,7 +7182,7 @@ class PollsCompanion extends UpdateCompanion { Expression? createdById, Expression? createdAt, Expression? updatedAt, - Expression? extraData, + Expression? custom, Expression? rowid, }) { return RawValuesInsertable({ @@ -7204,7 +7204,7 @@ class PollsCompanion extends UpdateCompanion { if (createdById != null) 'created_by_id': createdById, if (createdAt != null) 'created_at': createdAt, if (updatedAt != null) 'updated_at': updatedAt, - if (extraData != null) 'extra_data': extraData, + if (custom != null) 'custom': custom, if (rowid != null) 'rowid': rowid, }); } @@ -7228,7 +7228,7 @@ class PollsCompanion extends UpdateCompanion { Value? createdById, Value? createdAt, Value? updatedAt, - Value?>? extraData, + Value?>? custom, Value? rowid, }) { return PollsCompanion( @@ -7250,7 +7250,7 @@ class PollsCompanion extends UpdateCompanion { createdById: createdById ?? this.createdById, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, - extraData: extraData ?? this.extraData, + custom: custom ?? this.custom, rowid: rowid ?? this.rowid, ); } @@ -7326,9 +7326,9 @@ class PollsCompanion extends UpdateCompanion { if (updatedAt.present) { map['updated_at'] = Variable(updatedAt.value); } - if (extraData.present) { - map['extra_data'] = Variable( - $PollsTable.$converterextraDatan.toSql(extraData.value), + if (custom.present) { + map['custom'] = Variable( + $PollsTable.$convertercustomn.toSql(custom.value), ); } if (rowid.present) { @@ -7358,7 +7358,7 @@ class PollsCompanion extends UpdateCompanion { ..write('createdById: $createdById, ') ..write('createdAt: $createdAt, ') ..write('updatedAt: $updatedAt, ') - ..write('extraData: $extraData, ') + ..write('custom: $custom, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -16097,7 +16097,7 @@ typedef $$PollsTableCreateCompanionBuilder = Value createdById, Value createdAt, Value updatedAt, - Value?> extraData, + Value?> custom, Value rowid, }); typedef $$PollsTableUpdateCompanionBuilder = @@ -16120,7 +16120,7 @@ typedef $$PollsTableUpdateCompanionBuilder = Value createdById, Value createdAt, Value updatedAt, - Value?> extraData, + Value?> custom, Value rowid, }); @@ -16246,11 +16246,10 @@ class $$PollsTableFilterComposer extends Composer<_$DriftChatDatabase, $PollsTab builder: (column) => ColumnFilters(column), ); - ColumnWithTypeConverterFilters?, Map, String> get extraData => - $composableBuilder( - column: $table.extraData, - builder: (column) => ColumnWithTypeConverterFilters(column), - ); + ColumnWithTypeConverterFilters?, Map, String> get custom => $composableBuilder( + column: $table.custom, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); Expression pollVotesRefs( Expression Function($$PollVotesTableFilterComposer f) f, @@ -16375,8 +16374,8 @@ class $$PollsTableOrderingComposer extends Composer<_$DriftChatDatabase, $PollsT builder: (column) => ColumnOrderings(column), ); - ColumnOrderings get extraData => $composableBuilder( - column: $table.extraData, + ColumnOrderings get custom => $composableBuilder( + column: $table.custom, builder: (column) => ColumnOrderings(column), ); } @@ -16457,8 +16456,8 @@ class $$PollsTableAnnotationComposer extends Composer<_$DriftChatDatabase, $Poll GeneratedColumn get updatedAt => $composableBuilder(column: $table.updatedAt, builder: (column) => column); - GeneratedColumnWithTypeConverter?, String> get extraData => - $composableBuilder(column: $table.extraData, builder: (column) => column); + GeneratedColumnWithTypeConverter?, String> get custom => + $composableBuilder(column: $table.custom, builder: (column) => column); Expression pollVotesRefs( Expression Function($$PollVotesTableAnnotationComposer a) f, @@ -16528,7 +16527,7 @@ class $$PollsTableTableManager Value createdById = const Value.absent(), Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => PollsCompanion( id: id, @@ -16549,7 +16548,7 @@ class $$PollsTableTableManager createdById: createdById, createdAt: createdAt, updatedAt: updatedAt, - extraData: extraData, + custom: custom, rowid: rowid, ), createCompanionCallback: @@ -16572,7 +16571,7 @@ class $$PollsTableTableManager Value createdById = const Value.absent(), Value createdAt = const Value.absent(), Value updatedAt = const Value.absent(), - Value?> extraData = const Value.absent(), + Value?> custom = const Value.absent(), Value rowid = const Value.absent(), }) => PollsCompanion.insert( id: id, @@ -16593,7 +16592,7 @@ class $$PollsTableTableManager createdById: createdById, createdAt: createdAt, updatedAt: updatedAt, - extraData: extraData, + custom: custom, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/packages/stream_chat_persistence/lib/src/entity/polls.dart b/packages/stream_chat_persistence/lib/src/entity/polls.dart index 9df0208433..ec765000e3 100644 --- a/packages/stream_chat_persistence/lib/src/entity/polls.dart +++ b/packages/stream_chat_persistence/lib/src/entity/polls.dart @@ -71,8 +71,8 @@ class Polls extends Table { /// The date when the poll was last updated. DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)(); - /// Map of custom poll extraData - TextColumn get extraData => text().nullable().map(MapConverter())(); + /// Custom data attached to the poll. + TextColumn get custom => text().nullable().map(MapConverter())(); @override Set get primaryKey => {id}; diff --git a/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart b/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart index 3f6e93a83e..2637856e06 100644 --- a/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart +++ b/packages/stream_chat_persistence/lib/src/mapper/poll_mapper.dart @@ -18,10 +18,7 @@ extension PollEntityX on PollEntity { nameI18n: nameI18n, description: description, descriptionI18n: descriptionI18n, - options: options.map((it) { - final json = jsonDecode(it); - return PollOption.fromJson(json); - }).toList(), + options: options.map((it) => PollOption.fromData(jsonDecode(it))).toList(), votingVisibility: votingVisibility, enforceUniqueVote: enforceUniqueVote, maxVotesAllowed: maxVotesAllowed, @@ -38,7 +35,7 @@ extension PollEntityX on PollEntity { createdById: createdById, createdBy: createdBy, ownVotesAndAnswers: ownVotesAndAnswers, - extraData: extraData ?? {}, + custom: custom ?? {}, ); } } @@ -52,9 +49,7 @@ extension PollX on Poll { nameI18n: nameI18n, description: description, descriptionI18n: descriptionI18n, - // `PollOption.toJson` leaves out the server-owned `text_i18n`, as it is - // also the payload sent to the API; the cache has to keep it. - options: options.map((it) => jsonEncode({...it.toJson(), 'text_i18n': ?it.textI18n})).toList(), + options: options.map((it) => jsonEncode(it.toData())).toList(), votingVisibility: votingVisibility, enforceUniqueVote: enforceUniqueVote, maxVotesAllowed: maxVotesAllowed, @@ -67,6 +62,6 @@ extension PollX on Poll { voteCountsByOption: voteCountsByOption, voteCount: voteCount, createdById: createdById, - extraData: extraData, + custom: custom, ); } diff --git a/packages/stream_chat_persistence/test/src/converter/voting_visibility_converter_test.dart b/packages/stream_chat_persistence/test/src/converter/voting_visibility_converter_test.dart index f99a44e707..83d11cde32 100644 --- a/packages/stream_chat_persistence/test/src/converter/voting_visibility_converter_test.dart +++ b/packages/stream_chat_persistence/test/src/converter/voting_visibility_converter_test.dart @@ -16,11 +16,8 @@ void main() { expect(converter.fromSql('public'), VotingVisibility.public); }); - test('fromSql throws ArgumentError for invalid String', () { - expect( - () => converter.fromSql('invalid_value'), - throwsA(isA()), - ); + test('fromSql keeps a visibility the SDK does not name', () { + expect(converter.fromSql('members_only'), const VotingVisibility('members_only')); }); }); } diff --git a/packages/stream_chat_persistence/test/src/dao/poll_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/poll_dao_test.dart index f01a54f543..cf8bfafc23 100644 --- a/packages/stream_chat_persistence/test/src/dao/poll_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/poll_dao_test.dart @@ -70,7 +70,7 @@ void main() { createdAt: DateTime.now(), updatedAt: DateTime.now(), enforceUniqueVote: false, - extraData: const {'test_extra_data': 'extraData'}, + custom: const {'test_extra_data': 'extraData'}, ); polls.add(poll); diff --git a/packages/stream_chat_persistence/test/src/dao/poll_vote_dao_test.dart b/packages/stream_chat_persistence/test/src/dao/poll_vote_dao_test.dart index 9228d1d75b..faf93700fe 100644 --- a/packages/stream_chat_persistence/test/src/dao/poll_vote_dao_test.dart +++ b/packages/stream_chat_persistence/test/src/dao/poll_vote_dao_test.dart @@ -59,7 +59,7 @@ void main() { updatedAt: DateTime.now(), enforceUniqueVote: false, allowAnswers: true, - extraData: const {'test_extra_data': 'extraData'}, + custom: const {'test_extra_data': 'extraData'}, ); final votes = latestVotesByOption.values.expand((it) => it).toList(); diff --git a/packages/stream_chat_persistence/test/src/mapper/poll_mapper_test.dart b/packages/stream_chat_persistence/test/src/mapper/poll_mapper_test.dart index a3a453dd9d..87bdbc8b3c 100644 --- a/packages/stream_chat_persistence/test/src/mapper/poll_mapper_test.dart +++ b/packages/stream_chat_persistence/test/src/mapper/poll_mapper_test.dart @@ -56,10 +56,10 @@ void main() { votingVisibility: VotingVisibility.public, allowUserSuggestedOptions: true, options: const [ - PollOption(id: 'option-1', text: 'Red'), - PollOption(id: 'option-2', text: 'Blue'), - PollOption(id: 'option-3', text: 'Green'), - ].map(jsonEncode).toList(), + '{"id":"option-1","text":"Red","custom":{"hex":"#f00"}}', + '{"id":"option-2","text":"Blue","custom":{}}', + '{"id":"option-3","text":"Green","custom":{}}', + ], voteCount: voteCountsByOption.values.reduce((a, b) => a + b), voteCountsByOption: voteCountsByOption, createdAt: DateTime.now(), @@ -68,7 +68,7 @@ void main() { isClosed: false, allowAnswers: true, answersCount: latestAnswers.length, - extraData: {'test_extra_data': 'extraData'}, + custom: {'test_extra_data': 'extraData'}, ); final poll = entity.toPoll( createdBy: currentUser, @@ -88,14 +88,11 @@ void main() { expect(poll.enforceUniqueVote, entity.enforceUniqueVote); expect(poll.maxVotesAllowed, entity.maxVotesAllowed); expect(poll.allowUserSuggestedOptions, entity.allowUserSuggestedOptions); - for (var i = 0; i < poll.options.length; i++) { - final pollOption = poll.options[i]; - final entityOptionJson = jsonDecode(entity.options[i]); - final entityOption = PollOption.fromJson(entityOptionJson); - expect(pollOption.id, entityOption.id); - expect(pollOption.text, entityOption.text); - expect(pollOption.extraData, entityOption.extraData); - } + expect(poll.options, const [ + PollOption(id: 'option-1', text: 'Red', custom: {'hex': '#f00'}), + PollOption(id: 'option-2', text: 'Blue'), + PollOption(id: 'option-3', text: 'Green'), + ]); expect(poll.allowAnswers, entity.allowAnswers); expect(poll.answersCount, entity.answersCount); expect(poll.isClosed, entity.isClosed); @@ -104,7 +101,7 @@ void main() { expect(poll.voteCountsByOption, entity.voteCountsByOption); expect(poll.voteCount, entity.voteCount); expect(poll.createdById, entity.createdById); - expect(poll.extraData, entity.extraData); + expect(poll.custom, entity.custom); }); test('toEntity should map poll into PollEntity', () { @@ -154,7 +151,7 @@ void main() { createdById: currentUser.id, allowUserSuggestedOptions: true, options: const [ - PollOption(id: 'option-1', text: 'Red'), + PollOption(id: 'option-1', text: 'Red', custom: {'hex': '#f00'}), PollOption(id: 'option-2', text: 'Blue'), PollOption(id: 'option-3', text: 'Green'), ], @@ -165,7 +162,7 @@ void main() { enforceUniqueVote: false, allowAnswers: true, answersCount: latestAnswers.length, - extraData: const {'test_extra_data': 'extraData'}, + custom: const {'test_extra_data': 'extraData'}, ); final entity = poll.toEntity(); expect(entity, isA()); @@ -176,10 +173,11 @@ void main() { expect(entity.enforceUniqueVote, poll.enforceUniqueVote); expect(entity.maxVotesAllowed, poll.maxVotesAllowed); expect(entity.allowUserSuggestedOptions, poll.allowUserSuggestedOptions); - expect( - entity.options, - poll.options.map((it) => jsonEncode(it.toJson())).toList(), - ); + expect(entity.options, const [ + '{"id":"option-1","text":"Red","custom":{"hex":"#f00"}}', + '{"id":"option-2","text":"Blue","custom":{}}', + '{"id":"option-3","text":"Green","custom":{}}', + ]); expect(entity.allowAnswers, poll.allowAnswers); expect(entity.answersCount, poll.answersCount); expect(entity.isClosed, poll.isClosed); @@ -188,7 +186,7 @@ void main() { expect(entity.voteCountsByOption, poll.voteCountsByOption); expect(entity.voteCount, poll.voteCount); expect(entity.createdById, poll.createdById); - expect(entity.extraData, poll.extraData); + expect(entity.custom, poll.custom); }); test('a poll keeps the translations of its name, description and options through the cache', () {