TEXTBOOK SECTION / AI LEARNING

Flutterでトレンド一覧アプリを作る

Flutterアプリケーション開発概論の「SNSトレンド自動収集」より、Flutterでトレンド一覧アプリを作るを解説。生成AI、AI活用、DX、業務改善を実践しながら学べるオンライン教材です。

6SNSトレンド自動収集Flutter / iOS / Android / MacOS / Windows / 基礎から学ぶ / 開発 / アプリ開発

OVERVIEW

この節で学べること

概要を表示する
項目内容
教材名Flutterアプリケーション開発概論
SNSトレンド自動収集
Flutterでトレンド一覧アプリを作る
カテゴリFlutter / iOS / Android / MacOS / Windows / 基礎から学ぶ / 開発 / アプリ開発
学習内容生成AI、AI活用、DX、業務改善を実践しながら理解するための教材です。

TABLE OF CONTENTS

目次

CONTENT

ここから

前のページでは、投稿の新しさ、反応数、増加速度、複数情報源での掲載状況から、0点から100点の話題度を計算しました。

このページでは、Supabaseへ保存されたtrend_itemsをFlutterアプリから読み込みます。

完成する画面では、次の操作ができるようにします。

トレンド一覧
├─ 話題度が高い順に表示する
├─ 新しい順に表示する
├─ Mastodonだけに絞り込む
├─ Blueskyだけに絞り込む
├─ RSSだけに絞り込む
├─ YouTubeだけに絞り込む
├─ キーワードで検索する
├─ 元投稿を外部ブラウザで開く
├─ 投稿をブックマークする
└─ ブックマーク済みだけを表示する

Flutterアプリは、SNSのAPIを直接呼び出しません。

Flutterアプリ
      ↓
Supabaseのtrend_itemsを読み取る
      ↓
一覧表示・検索・絞り込み

Supabaseへの接続には、Flutterへ配置できるPublishable keyを使用します。

投稿の追加や更新に使用するSecret keyは、Flutterアプリへ入れません。


6.1 Supabaseから投稿を取得する

最初に、FlutterアプリからSupabaseへ接続できるようにします。

第2ページで追加したパッケージを確認します。

cd app

flutter pub add supabase_flutter
flutter pub add flutter_riverpod
flutter pub add url_launcher
flutter pub add shared_preferences

すでに追加済みの場合は、再度実行する必要はありません。

環境変数を定義する

次のファイルを作成します。

app/lib/core/config/app_environment.dart
/// 役割:
/// Flutterのビルド時に渡された公開設定値を保持する。
abstract final class AppEnvironment {
  /// SupabaseプロジェクトのURL。
  static const String supabaseUrl = String.fromEnvironment(
    'SUPABASE_URL',
  );

  /// Flutterアプリから使用するSupabase Publishable key。
  static const String supabasePublishableKey =
      String.fromEnvironment(
    'SUPABASE_PUBLISHABLE_KEY',
  );

  /// 役割:
  /// 必須の設定値が渡されていることを確認する。
  ///
  /// 入力:
  /// なし。
  ///
  /// 出力:
  /// 設定が有効な場合は何も返さない。
  /// 不足している場合はStateErrorを発生させる。
  static void validate() {
    if (supabaseUrl.trim().isEmpty) {
      throw StateError(
        'SUPABASE_URL is not configured.',
      );
    }

    if (supabasePublishableKey.trim().isEmpty) {
      throw StateError(
        'SUPABASE_PUBLISHABLE_KEY is not configured.',
      );
    }
  }
}

Flutterへ渡すのは、次の2つだけです。

SUPABASE_URL
SUPABASE_PUBLISHABLE_KEY

次の情報はFlutterへ入れてはいけません。

SUPABASE_SECRET_KEY
service_role key
データベースパスワード
YouTube APIキー
外部サービスの秘密トークン

Supabaseを初期化する

次のファイルを置き換えます。

app/lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import 'app/app.dart';
import 'core/config/app_environment.dart';

/// 役割:
/// Flutter、Supabase、Riverpodを初期化してアプリを起動する。
///
/// 入力:
/// なし。
///
/// 出力:
/// なし。
Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  AppEnvironment.validate();

  await Supabase.initialize(
    url: AppEnvironment.supabaseUrl,
    anonKey: AppEnvironment.supabasePublishableKey,
  );

  runApp(
    const ProviderScope(
      child: SnsTrendApp(),
    ),
  );
}

ビルド時に設定値を渡す

アプリを起動するときは、次のように設定値を渡します。

flutter run \
  --dart-define=SUPABASE_URL=SupabaseプロジェクトのURL \
  --dart-define=SUPABASE_PUBLISHABLE_KEY=Publishableキー

コマンドへ直接設定値を書きたくない場合は、JSONファイルを使用できます。

次のファイルを作成します。

app/env/development.json
{
  "SUPABASE_URL": "SupabaseプロジェクトのURL",
  "SUPABASE_PUBLISHABLE_KEY": "Publishableキー"
}

起動時に指定します。

flutter run \
  --dart-define-from-file=env/development.json

このファイルをGitへ登録しない場合は、.gitignoreへ追加します。

app/env/*.json
!app/env/example.json

共有用として、値を空にしたファイルだけを登録できます。

app/env/example.json
{
  "SUPABASE_URL": "",
  "SUPABASE_PUBLISHABLE_KEY": ""
}

Publishable keyは公開環境で使うことを前提としたキーですが、環境ごとの設定を混同しないため、設定ファイルを分けて管理します。


6.2 Repository層を作成する

Flutterの画面からSupabaseを直接呼び出すと、画面表示とデータ取得の責務が混在します。

望ましくない構成

画面
├─ Supabaseへ接続
├─ JSONを変換
├─ 並び替え
├─ エラー処理
└─ Widgetを表示

本教材では、次のように分離します。

画面
  ↓
状態管理
  ↓
Repository
  ↓
Supabase

Repository層は、次の処理を担当します。

  • Supabaseから行データを取得する
  • JSONをTrendItemへ変換する
  • 情報源で絞り込む
  • 話題度順または新着順に並べる
  • 不正なデータを除外する
  • 通信エラーを上位層へ伝える

Flutter側のTrendSourceを作成する

次のファイルを作成します。

app/lib/features/trends/domain/models/trend_source.dart
/// 役割:
/// Flutterアプリで扱う情報源の種類を表す。
enum TrendSource {
  mastodonTag('mastodon_tag'),
  mastodonStatus('mastodon_status'),
  bluesky('bluesky'),
  rss('rss'),
  youtube('youtube');

  const TrendSource(this.databaseValue);

  /// Supabaseへ保存されている文字列。
  final String databaseValue;

  /// 役割:
  /// Supabaseの文字列からTrendSourceを取得する。
  ///
  /// 入力:
  /// データベースに保存されたsource値。
  ///
  /// 出力:
  /// 一致するTrendSource。
  /// 一致しない場合はnull。
  static TrendSource? tryParse(String value) {
    for (final source in TrendSource.values) {
      if (source.databaseValue == value) {
        return source;
      }
    }

    return null;
  }
}

並び替え方法を定義する

次のファイルを作成します。

app/lib/features/trends/domain/models/trend_sort.dart
/// 役割:
/// トレンド一覧の並び替え方法を表す。
enum TrendSort {
  trendScore,
  newest;

  /// Supabaseで最初に並び替えるカラム名。
  String get databaseColumn {
    return switch (this) {
      TrendSort.trendScore => 'trend_score',
      TrendSort.newest => 'published_at',
    };
  }
}

Supabaseへの取得条件を定義する

次のファイルを作成します。

app/lib/features/trends/domain/models/trend_request.dart
import 'trend_sort.dart';
import 'trend_source.dart';

/// 役割:
/// Supabaseへ要求する取得条件を保持する。
final class TrendRequest {
  const TrendRequest({
    this.source,
    this.sort = TrendSort.trendScore,
  });

  /// nullの場合はすべての情報源を取得する。
  final TrendSource? source;

  /// 一覧の並び替え方法。
  final TrendSort sort;

  /// 役割:
  /// 情報源を変更した新しい取得条件を作成する。
  ///
  /// 入力:
  /// 新しい情報源。nullの場合はすべて。
  ///
  /// 出力:
  /// 更新後のTrendRequest。
  TrendRequest withSource(TrendSource? value) {
    return TrendRequest(
      source: value,
      sort: sort,
    );
  }

  /// 役割:
  /// 並び替え方法を変更した新しい取得条件を作成する。
  ///
  /// 入力:
  /// 新しい並び替え方法。
  ///
  /// 出力:
  /// 更新後のTrendRequest。
  TrendRequest withSort(TrendSort value) {
    return TrendRequest(
      source: source,
      sort: value,
    );
  }
}

画面内の絞り込み条件を定義する

キーワード検索とブックマーク絞り込みは、Supabaseへ毎回問い合わせず、取得済みデータに対して行います。

次のファイルを作成します。

app/lib/features/trends/domain/models/trend_display_filter.dart
/// 役割:
/// 取得後のトレンド一覧へ適用する画面内フィルターを保持する。
final class TrendDisplayFilter {
  const TrendDisplayFilter({
    this.keyword = '',
    this.onlyBookmarked = false,
  });

  /// タイトル・本文・投稿者に適用する検索文字列。
  final String keyword;

  /// trueの場合はブックマーク済みだけを表示する。
  final bool onlyBookmarked;

  /// 役割:
  /// 検索キーワードを変更した新しい状態を作成する。
  ///
  /// 入力:
  /// 新しい検索キーワード。
  ///
  /// 出力:
  /// 更新後のTrendDisplayFilter。
  TrendDisplayFilter withKeyword(String value) {
    return TrendDisplayFilter(
      keyword: value,
      onlyBookmarked: onlyBookmarked,
    );
  }

  /// 役割:
  /// ブックマークのみ表示するかを変更する。
  ///
  /// 入力:
  /// 新しい表示状態。
  ///
  /// 出力:
  /// 更新後のTrendDisplayFilter。
  TrendDisplayFilter withOnlyBookmarked(
    bool value,
  ) {
    return TrendDisplayFilter(
      keyword: keyword,
      onlyBookmarked: value,
    );
  }
}

JSON読み取り用の処理を作成する

次のファイルを作成します。

app/lib/core/json/json_reader.dart
typedef JsonMap = Map<String, Object?>;

/// 役割:
/// 外部データを文字列キーのMapとして検証する。
///
/// 入力:
/// Supabaseなどから受け取った値。
///
/// 出力:
/// 検証済みのJsonMap。
JsonMap requireJsonMap(
  Object? value, {
  required String context,
}) {
  if (value is! Map<Object?, Object?>) {
    throw FormatException(
      '$context must be a JSON object.',
    );
  }

  final result = <String, Object?>{};

  for (final entry in value.entries) {
    final key = entry.key;

    if (key is! String) {
      throw FormatException(
        '$context contains a non-string key.',
      );
    }

    result[key] = entry.value;
  }

  return result;
}

/// 役割:
/// 必須文字列を読み取る。
///
/// 入力:
/// JSON、キー名、確認用の文脈。
///
/// 出力:
/// 空ではない文字列。
String requireString(
  JsonMap map,
  String key, {
  required String context,
}) {
  final value = map[key];

  if (value is String && value.trim().isNotEmpty) {
    return value;
  }

  throw FormatException(
    '$context.$key must be a non-empty string.',
  );
}

/// 役割:
/// 任意文字列を読み取る。
///
/// 入力:
/// JSONとキー名。
///
/// 出力:
/// 文字列。取得できない場合はnull。
String? optionalString(
  JsonMap map,
  String key,
) {
  final value = map[key];

  if (value is String && value.trim().isNotEmpty) {
    return value;
  }

  return null;
}

/// 役割:
/// 任意整数を読み取る。
///
/// 入力:
/// JSONとキー名。
///
/// 出力:
/// 整数へ変換できる値。取得できない場合はnull。
int? optionalInt(
  JsonMap map,
  String key,
) {
  final value = map[key];

  if (value is int) {
    return value;
  }

  if (value is num) {
    return value.toInt();
  }

  if (value is String) {
    return int.tryParse(value);
  }

  return null;
}

/// 役割:
/// 任意の小数を読み取る。
///
/// 入力:
/// JSONとキー名。
///
/// 出力:
/// doubleへ変換できる値。取得できない場合はnull。
double? optionalDouble(
  JsonMap map,
  String key,
) {
  final value = map[key];

  if (value is double) {
    return value;
  }

  if (value is num) {
    return value.toDouble();
  }

  if (value is String) {
    return double.tryParse(value);
  }

  return null;
}

/// 役割:
/// ISO 8601形式の日時を読み取る。
///
/// 入力:
/// JSONとキー名。
///
/// 出力:
/// UTC日時。取得できない場合はnull。
DateTime? optionalDateTime(
  JsonMap map,
  String key,
) {
  final value = optionalString(
    map,
    key,
  );

  if (value == null) {
    return null;
  }

  return DateTime.tryParse(value)?.toUtc();
}

TrendItemを作成する

次のファイルを作成します。

app/lib/features/trends/domain/models/trend_item.dart
import '../../../../core/json/json_reader.dart';
import 'trend_source.dart';

/// 役割:
/// Supabaseから読み取ったトレンド情報を保持する。
final class TrendItem {
  const TrendItem({
    required this.id,
    required this.source,
    required this.title,
    required this.text,
    required this.authorName,
    required this.publishedAt,
    required this.originalUrl,
    required this.thumbnailUrl,
    required this.likeCount,
    required this.commentCount,
    required this.shareCount,
    required this.trendScore,
    required this.collectedAt,
  });

  final String id;
  final TrendSource source;
  final String title;
  final String text;
  final String? authorName;
  final DateTime? publishedAt;
  final String originalUrl;
  final String? thumbnailUrl;
  final int? likeCount;
  final int? commentCount;
  final int? shareCount;
  final double trendScore;
  final DateTime collectedAt;

  /// キーワード検索に使用する結合済み文字列。
  String get searchableText {
    return <String>[
      title,
      text,
      authorName ?? '',
    ].join('\n').toLowerCase();
  }

  /// 役割:
  /// Supabaseの行データからTrendItemを作成する。
  ///
  /// 入力:
  /// Supabaseから取得したJSON。
  ///
  /// 出力:
  /// 検証済みのTrendItem。
  factory TrendItem.fromJson(JsonMap json) {
    final sourceValue = requireString(
      json,
      'source',
      context: 'TrendItem',
    );

    final source = TrendSource.tryParse(
      sourceValue,
    );

    if (source == null) {
      throw FormatException(
        'Unknown trend source: $sourceValue',
      );
    }

    final collectedAt = optionalDateTime(
      json,
      'collected_at',
    );

    if (collectedAt == null) {
      throw const FormatException(
        'TrendItem.collected_at is invalid.',
      );
    }

    return TrendItem(
      id: requireString(
        json,
        'id',
        context: 'TrendItem',
      ),
      source: source,
      title: requireString(
        json,
        'title',
        context: 'TrendItem',
      ),
      text: optionalString(
            json,
            'text',
          ) ??
          '',
      authorName: optionalString(
        json,
        'author_name',
      ),
      publishedAt: optionalDateTime(
        json,
        'published_at',
      ),
      originalUrl: requireString(
        json,
        'original_url',
        context: 'TrendItem',
      ),
      thumbnailUrl: optionalString(
        json,
        'thumbnail_url',
      ),
      likeCount: optionalInt(
        json,
        'like_count',
      ),
      commentCount: optionalInt(
        json,
        'comment_count',
      ),
      shareCount: optionalInt(
        json,
        'share_count',
      ),
      trendScore: optionalDouble(
            json,
            'trend_score',
          ) ??
          0,
      collectedAt: collectedAt,
    );
  }
}

Repositoryのインターフェースを作成する

次のファイルを作成します。

app/lib/features/trends/domain/repositories/trend_repository.dart
import '../models/trend_item.dart';
import '../models/trend_request.dart';

/// 役割:
/// トレンド情報の取得方法を抽象化する。
abstract interface class TrendRepository {
  /// 役割:
  /// 指定条件に一致するトレンド情報を取得する。
  ///
  /// 入力:
  /// 情報源と並び替え条件。
  ///
  /// 出力:
  /// TrendItemの一覧。
  Future<List<TrendItem>> fetchTrends({
    required TrendRequest request,
  });
}

画面側は、データがSupabaseから来ていることを意識しません。

将来、REST APIやローカルDBへ変更した場合も、Repositoryの実装だけを交換できます。

Supabase用Repositoryを作成する

次のファイルを作成します。

app/lib/features/trends/data/repositories/supabase_trend_repository.dart
import 'package:supabase_flutter/supabase_flutter.dart';

import '../../../../core/json/json_reader.dart';
import '../../domain/models/trend_item.dart';
import '../../domain/models/trend_request.dart';
import '../../domain/models/trend_sort.dart';
import '../../domain/repositories/trend_repository.dart';

/// 役割:
/// Supabaseのtrend_itemsテーブルから情報を取得する。
final class SupabaseTrendRepository
    implements TrendRepository {
  const SupabaseTrendRepository({
    required SupabaseClient client,
  }) : _client = client;

  final SupabaseClient _client;

  static const String _selectedColumns = '''
    id,
    source,
    title,
    text,
    author_name,
    published_at,
    original_url,
    thumbnail_url,
    like_count,
    comment_count,
    share_count,
    trend_score,
    collected_at
  ''';

  /// 1回に取得する最大件数。
  static const int _fetchLimit = 300;

  @override
  Future<List<TrendItem>> fetchTrends({
    required TrendRequest request,
  }) async {
    final Object? response = await _fetchRows(
      request,
    );

    if (response is! List<Object?>) {
      throw const FormatException(
        'Supabase response must be a list.',
      );
    }

    final items = <TrendItem>[];

    for (final row in response) {
      try {
        final json = requireJsonMap(
          row,
          context: 'trend_items row',
        );

        items.add(
          TrendItem.fromJson(json),
        );
      } on FormatException catch (error) {
        // 1件の不正データによって一覧全体を停止させない。
        print(
          'Skipped invalid trend item: $error',
        );
      }
    }

    _sortItems(
      items: items,
      sort: request.sort,
    );

    return List<TrendItem>.unmodifiable(
      items,
    );
  }

  /// 役割:
  /// 情報源の有無に応じたSupabaseクエリを実行する。
  ///
  /// 入力:
  /// 取得条件。
  ///
  /// 出力:
  /// Supabaseから返された未検証データ。
  Future<Object?> _fetchRows(
    TrendRequest request,
  ) async {
    final source = request.source;

    if (source == null) {
      return _client
          .from('trend_items')
          .select(_selectedColumns)
          .order(
            request.sort.databaseColumn,
            ascending: false,
          )
          .limit(_fetchLimit);
    }

    return _client
        .from('trend_items')
        .select(_selectedColumns)
        .eq(
          'source',
          source.databaseValue,
        )
        .order(
          request.sort.databaseColumn,
          ascending: false,
        )
        .limit(_fetchLimit);
  }

  /// 役割:
  /// 取得した投稿を指定条件で並べ替える。
  ///
  /// 入力:
  /// 投稿一覧と並び替え方法。
  ///
  /// 出力:
  /// なし。受け取ったListを並べ替える。
  void _sortItems({
    required List<TrendItem> items,
    required TrendSort sort,
  }) {
    items.sort((left, right) {
      return switch (sort) {
        TrendSort.trendScore =>
          _compareByTrendScore(
            left,
            right,
          ),
        TrendSort.newest =>
          _compareByPublishedAt(
            left,
            right,
          ),
      };
    });
  }

  /// 役割:
  /// 話題度が高い順に比較する。
  ///
  /// 入力:
  /// 比較する2件のTrendItem。
  ///
  /// 出力:
  /// List.sortで使用する比較結果。
  int _compareByTrendScore(
    TrendItem left,
    TrendItem right,
  ) {
    final scoreResult =
        right.trendScore.compareTo(
      left.trendScore,
    );

    if (scoreResult != 0) {
      return scoreResult;
    }

    return _compareByPublishedAt(
      left,
      right,
    );
  }

  /// 役割:
  /// 公開日時が新しい順に比較する。
  ///
  /// 入力:
  /// 比較する2件のTrendItem。
  ///
  /// 出力:
  /// List.sortで使用する比較結果。
  int _compareByPublishedAt(
    TrendItem left,
    TrendItem right,
  ) {
    final leftDate = left.publishedAt;
    final rightDate = right.publishedAt;

    if (leftDate == null && rightDate == null) {
      return right.collectedAt.compareTo(
        left.collectedAt,
      );
    }

    if (leftDate == null) {
      return 1;
    }

    if (rightDate == null) {
      return -1;
    }

    return rightDate.compareTo(leftDate);
  }
}

本教材では、1回の取得を300件に制限しています。

すべての投稿を一度に取得すると、データ数の増加に応じて通信量や描画負荷が増えるためです。

MVP完成後にデータ量が増えた場合は、ページネーションを追加します。


6.3 状態管理を実装する

状態管理にはRiverpodを使用します。

状態を次の4種類に分けます。

取得条件
├─ 情報源
└─ 並び替え方法

画面内フィルター
├─ 検索キーワード
└─ ブックマーク済みのみ

非同期データ
└─ Supabaseから取得したTrendItem

端末内データ
└─ ブックマークID

検索キーワードを入力するたびにSupabaseへ再接続しないよう、取得条件と画面内フィルターを分離します。

取得条件のNotifierを作成する

次のファイルを作成します。

app/lib/features/trends/presentation/providers/trend_request_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../domain/models/trend_request.dart';
import '../../domain/models/trend_sort.dart';
import '../../domain/models/trend_source.dart';

/// Supabaseへ送る取得条件を提供する。
final trendRequestProvider =
    NotifierProvider<
      TrendRequestNotifier,
      TrendRequest
    >(
  TrendRequestNotifier.new,
);

/// 役割:
/// 情報源と並び替え方法の変更を管理する。
final class TrendRequestNotifier
    extends Notifier<TrendRequest> {
  @override
  TrendRequest build() {
    return const TrendRequest();
  }

  /// 役割:
  /// 表示する情報源を変更する。
  ///
  /// 入力:
  /// TrendSource。nullの場合はすべて。
  ///
  /// 出力:
  /// なし。
  void setSource(TrendSource? source) {
    state = state.withSource(source);
  }

  /// 役割:
  /// 一覧の並び替え方法を変更する。
  ///
  /// 入力:
  /// TrendSort。
  ///
  /// 出力:
  /// なし。
  void setSort(TrendSort sort) {
    state = state.withSort(sort);
  }
}

画面内フィルターのNotifierを作成する

次のファイルを作成します。

app/lib/features/trends/presentation/providers/trend_display_filter_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../domain/models/trend_display_filter.dart';

/// 画面内で使用する絞り込み条件を提供する。
final trendDisplayFilterProvider =
    NotifierProvider<
      TrendDisplayFilterNotifier,
      TrendDisplayFilter
    >(
  TrendDisplayFilterNotifier.new,
);

/// 役割:
/// キーワード検索とブックマーク絞り込みを管理する。
final class TrendDisplayFilterNotifier
    extends Notifier<TrendDisplayFilter> {
  @override
  TrendDisplayFilter build() {
    return const TrendDisplayFilter();
  }

  /// 役割:
  /// キーワード検索条件を変更する。
  ///
  /// 入力:
  /// 入力された文字列。
  ///
  /// 出力:
  /// なし。
  void setKeyword(String keyword) {
    state = state.withKeyword(
      keyword.trim(),
    );
  }

  /// 役割:
  /// ブックマーク済みだけを表示するか変更する。
  ///
  /// 入力:
  /// trueの場合はブックマーク済みだけを表示する。
  ///
  /// 出力:
  /// なし。
  void setOnlyBookmarked(bool value) {
    state = state.withOnlyBookmarked(
      value,
    );
  }
}

Repository Providerを作成する

次のファイルを作成します。

app/lib/features/trends/presentation/providers/trend_repository_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';

import '../../data/repositories/supabase_trend_repository.dart';
import '../../domain/repositories/trend_repository.dart';

/// 役割:
/// TrendRepositoryの実装をアプリ全体へ提供する。
final trendRepositoryProvider =
    Provider<TrendRepository>((ref) {
  return SupabaseTrendRepository(
    client: Supabase.instance.client,
  );
});

非同期一覧を管理する

次のファイルを作成します。

app/lib/features/trends/presentation/providers/trend_list_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../domain/models/trend_item.dart';
import 'trend_repository_provider.dart';
import 'trend_request_notifier.dart';

/// Supabaseから取得した未加工の一覧を提供する。
final rawTrendItemsProvider =
    AsyncNotifierProvider<
      TrendListNotifier,
      List<TrendItem>
    >(
  TrendListNotifier.new,
);

/// 役割:
/// Supabaseからのトレンド取得と再読み込みを管理する。
final class TrendListNotifier
    extends AsyncNotifier<List<TrendItem>> {
  @override
  Future<List<TrendItem>> build() async {
    final request = ref.watch(
      trendRequestProvider,
    );

    final repository = ref.watch(
      trendRepositoryProvider,
    );

    return repository.fetchTrends(
      request: request,
    );
  }

  /// 役割:
  /// 現在の取得条件を使ってデータを再取得する。
  ///
  /// 入力:
  /// なし。
  ///
  /// 出力:
  /// 再取得完了を表すFuture。
  Future<void> reload() async {
    final request = ref.read(
      trendRequestProvider,
    );

    final repository = ref.read(
      trendRepositoryProvider,
    );

    state = const AsyncLoading<List<TrendItem>>();

    state = await AsyncValue.guard(
      () {
        return repository.fetchTrends(
          request: request,
        );
      },
    );
  }
}

AsyncValueによって、次の3状態を一つの型で管理できます。

AsyncLoading
└─ 読み込み中

AsyncData
└─ 取得成功

AsyncError
└─ 取得失敗

画面側で、別々のisLoadingerrorMessageを用意する必要がありません。


6.4 トレンド一覧をカード表示する

一覧カードには、次の情報を表示します。

トレンドカード
├─ 情報源
├─ 話題度
├─ タイトル
├─ 本文の一部
├─ 投稿者
├─ 公開日時
├─ いいね数
├─ コメント数
├─ 共有数
├─ サムネイル
├─ 元投稿を開くボタン
└─ ブックマークボタン

相対時間を整形する

追加パッケージを使わず、簡単な相対時刻を作成します。

次のファイルを作成します。

app/lib/core/formatters/relative_time_formatter.dart
/// 役割:
/// 日時を「3時間前」などの相対表記へ変換する。
///
/// 入力:
/// 表示対象の日時と、比較に使用する現在日時。
///
/// 出力:
/// 日本語の相対時間。日時がない場合は「日時不明」。
String formatRelativeTime(
  DateTime? value, {
  DateTime? now,
}) {
  if (value == null) {
    return '日時不明';
  }

  final currentTime =
      (now ?? DateTime.now()).toUtc();

  final targetTime = value.toUtc();
  final difference = currentTime.difference(
    targetTime,
  );

  if (difference.isNegative) {
    return 'たった今';
  }

  if (difference.inMinutes < 1) {
    return 'たった今';
  }

  if (difference.inHours < 1) {
    return '${difference.inMinutes}分前';
  }

  if (difference.inDays < 1) {
    return '${difference.inHours}時間前';
  }

  if (difference.inDays < 7) {
    return '${difference.inDays}日前';
  }

  final localDate = targetTime.toLocal();

  return '${localDate.year}/'
      '${localDate.month.toString().padLeft(2, '0')}/'
      '${localDate.day.toString().padLeft(2, '0')}';
}

件数を短く表示する

次のファイルを作成します。

app/lib/core/formatters/count_formatter.dart
/// 役割:
/// 大きな件数を1.2万などの短い表記へ変換する。
///
/// 入力:
/// 0以上の件数。
///
/// 出力:
/// 画面表示用の文字列。
String formatCompactCount(int value) {
  if (value >= 100000000) {
    final number = value / 100000000;

    return '${_removeTrailingZero(number)}億';
  }

  if (value >= 10000) {
    final number = value / 10000;

    return '${_removeTrailingZero(number)}万';
  }

  return value.toString();
}

/// 役割:
/// 小数第1位が0の場合に整数表記へ戻す。
///
/// 入力:
/// 表示する数値。
///
/// 出力:
/// 末尾の不要な0を除いた文字列。
String _removeTrailingZero(double value) {
  final fixed = value.toStringAsFixed(1);

  if (fixed.endsWith('.0')) {
    return fixed.substring(
      0,
      fixed.length - 2,
    );
  }

  return fixed;
}

情報源の表示を定義する

DomainモデルへFlutterのIconDataを持たせず、Presentation層で定義します。

次のファイルを作成します。

app/lib/features/trends/presentation/extensions/trend_source_presentation.dart
import 'package:flutter/material.dart';

import '../../domain/models/trend_source.dart';

/// 役割:
/// TrendSourceへ画面表示用の情報を追加する。
extension TrendSourcePresentation on TrendSource {
  /// 画面表示用の名称。
  String get label {
    return switch (this) {
      TrendSource.mastodonTag => 'Mastodonタグ',
      TrendSource.mastodonStatus => 'Mastodon',
      TrendSource.bluesky => 'Bluesky',
      TrendSource.rss => 'RSS',
      TrendSource.youtube => 'YouTube',
    };
  }

  /// 画面表示用のアイコン。
  IconData get icon {
    return switch (this) {
      TrendSource.mastodonTag => Icons.tag,
      TrendSource.mastodonStatus =>
        Icons.forum_outlined,
      TrendSource.bluesky => Icons.cloud_outlined,
      TrendSource.rss => Icons.rss_feed,
      TrendSource.youtube =>
        Icons.play_circle_outline,
    };
  }
}

トレンドカードを作成する

次のファイルを作成します。

app/lib/features/trends/presentation/widgets/trend_card.dart
import 'package:flutter/material.dart';

import '../../../../core/formatters/count_formatter.dart';
import '../../../../core/formatters/relative_time_formatter.dart';
import '../../domain/models/trend_item.dart';
import '../extensions/trend_source_presentation.dart';

/// 役割:
/// 1件のトレンド情報をカードとして表示する。
final class TrendCard extends StatelessWidget {
  const TrendCard({
    required this.item,
    required this.isBookmarked,
    required this.onOpen,
    required this.onToggleBookmark,
    super.key,
  });

  final TrendItem item;
  final bool isBookmarked;
  final VoidCallback onOpen;
  final VoidCallback onToggleBookmark;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Card(
      clipBehavior: Clip.antiAlias,
      margin: EdgeInsets.zero,
      child: InkWell(
        onTap: onOpen,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: <Widget>[
            if (item.thumbnailUrl != null)
              _TrendThumbnail(
                imageUrl: item.thumbnailUrl!,
              ),
            Padding(
              padding: const EdgeInsets.fromLTRB(
                16,
                14,
                10,
                14,
              ),
              child: Column(
                crossAxisAlignment:
                    CrossAxisAlignment.start,
                children: <Widget>[
                  Row(
                    children: <Widget>[
                      Icon(
                        item.source.icon,
                        size: 18,
                      ),
                      const SizedBox(width: 6),
                      Expanded(
                        child: Text(
                          item.source.label,
                          style: theme.textTheme.labelMedium,
                        ),
                      ),
                      _TrendScoreBadge(
                        score: item.trendScore,
                      ),
                      IconButton(
                        tooltip: isBookmarked
                            ? 'ブックマークを解除'
                            : 'ブックマークへ追加',
                        onPressed: onToggleBookmark,
                        icon: Icon(
                          isBookmarked
                              ? Icons.bookmark
                              : Icons.bookmark_border,
                        ),
                      ),
                    ],
                  ),
                  const SizedBox(height: 8),
                  Text(
                    item.title,
                    maxLines: 3,
                    overflow: TextOverflow.ellipsis,
                    style: theme.textTheme.titleMedium
                        ?.copyWith(
                      fontWeight: FontWeight.w700,
                      height: 1.45,
                    ),
                  ),
                  if (item.text.trim().isNotEmpty) ...[
                    const SizedBox(height: 8),
                    Text(
                      item.text,
                      maxLines: 3,
                      overflow: TextOverflow.ellipsis,
                      style: theme.textTheme.bodyMedium
                          ?.copyWith(
                        height: 1.55,
                      ),
                    ),
                  ],
                  const SizedBox(height: 12),
                  _TrendMetadata(item: item),
                  const SizedBox(height: 12),
                  Row(
                    children: <Widget>[
                      Expanded(
                        child: _EngagementSummary(
                          item: item,
                        ),
                      ),
                      TextButton.icon(
                        onPressed: onOpen,
                        icon: const Icon(
                          Icons.open_in_new,
                          size: 18,
                        ),
                        label: const Text(
                          '元投稿を見る',
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// 役割:
/// トレンドのサムネイル画像を表示する。
final class _TrendThumbnail extends StatelessWidget {
  const _TrendThumbnail({
    required this.imageUrl,
  });

  final String imageUrl;

  @override
  Widget build(BuildContext context) {
    return AspectRatio(
      aspectRatio: 16 / 9,
      child: Image.network(
        imageUrl,
        fit: BoxFit.cover,
        errorBuilder: (
          BuildContext context,
          Object error,
          StackTrace? stackTrace,
        ) {
          return const SizedBox.shrink();
        },
      ),
    );
  }
}

/// 役割:
/// 話題度を0点から100点のバッジとして表示する。
final class _TrendScoreBadge extends StatelessWidget {
  const _TrendScoreBadge({
    required this.score,
  });

  final double score;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);

    return Container(
      padding: const EdgeInsets.symmetric(
        horizontal: 9,
        vertical: 5,
      ),
      decoration: BoxDecoration(
        color: theme.colorScheme.primaryContainer,
        borderRadius: BorderRadius.circular(999),
      ),
      child: Text(
        '話題度 ${score.toStringAsFixed(0)}',
        style: theme.textTheme.labelMedium?.copyWith(
          color: theme.colorScheme.onPrimaryContainer,
          fontWeight: FontWeight.w700,
        ),
      ),
    );
  }
}

/// 役割:
/// 投稿者と公開日時を表示する。
final class _TrendMetadata extends StatelessWidget {
  const _TrendMetadata({
    required this.item,
  });

  final TrendItem item;

  @override
  Widget build(BuildContext context) {
    final values = <String>[
      if (item.authorName != null)
        item.authorName!,
      formatRelativeTime(
        item.publishedAt,
      ),
    ];

    return Text(
      values.join(' ・ '),
      maxLines: 1,
      overflow: TextOverflow.ellipsis,
      style: Theme.of(context)
          .textTheme
          .bodySmall,
    );
  }
}

/// 役割:
/// 取得できた反応数だけを表示する。
final class _EngagementSummary
    extends StatelessWidget {
  const _EngagementSummary({
    required this.item,
  });

  final TrendItem item;

  @override
  Widget build(BuildContext context) {
    final values = <Widget>[];

    if (item.likeCount != null) {
      values.add(
        _EngagementValue(
          icon: Icons.favorite_border,
          value: item.likeCount!,
          semanticLabel: 'いいね',
        ),
      );
    }

    if (item.commentCount != null) {
      values.add(
        _EngagementValue(
          icon: Icons.chat_bubble_outline,
          value: item.commentCount!,
          semanticLabel: 'コメント',
        ),
      );
    }

    if (item.shareCount != null) {
      values.add(
        _EngagementValue(
          icon: Icons.repeat,
          value: item.shareCount!,
          semanticLabel: '共有',
        ),
      );
    }

    if (values.isEmpty) {
      return const Text(
        '反応数の取得なし',
      );
    }

    return Wrap(
      spacing: 12,
      runSpacing: 6,
      children: values,
    );
  }
}

/// 役割:
/// 1種類の反応数をアイコン付きで表示する。
final class _EngagementValue
    extends StatelessWidget {
  const _EngagementValue({
    required this.icon,
    required this.value,
    required this.semanticLabel,
  });

  final IconData icon;
  final int value;
  final String semanticLabel;

  @override
  Widget build(BuildContext context) {
    return Semantics(
      label:
          '$semanticLabel ${value.toString()}件',
      child: Row(
        mainAxisSize: MainAxisSize.min,
        children: <Widget>[
          Icon(
            icon,
            size: 16,
          ),
          const SizedBox(width: 4),
          Text(
            formatCompactCount(value),
            style: Theme.of(context)
                .textTheme
                .bodySmall,
          ),
        ],
      ),
    );
  }
}

取得できない反応数は、0件として表示しません。

likeCount = 0
└─ ハートアイコンと「0」を表示する

likeCount = null
└─ いいね数自体を表示しない

6.5 SNS別に絞り込む

情報源の絞り込みには、横スクロールできるChoiceChipを使用します。

表示する選択肢は次のとおりです。

すべて
Mastodonタグ
Mastodon
Bluesky
RSS
YouTube

情報源フィルターを作成する

次のファイルを作成します。

app/lib/features/trends/presentation/widgets/trend_source_filter.dart
import 'package:flutter/material.dart';

import '../../domain/models/trend_source.dart';
import '../extensions/trend_source_presentation.dart';

/// 役割:
/// 情報源をChoiceChipで選択する。
final class TrendSourceFilter extends StatelessWidget {
  const TrendSourceFilter({
    required this.selectedSource,
    required this.onChanged,
    super.key,
  });

  final TrendSource? selectedSource;
  final ValueChanged<TrendSource?> onChanged;

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      height: 44,
      child: ListView(
        scrollDirection: Axis.horizontal,
        padding: const EdgeInsets.symmetric(
          horizontal: 16,
        ),
        children: <Widget>[
          ChoiceChip(
            label: const Text('すべて'),
            selected: selectedSource == null,
            onSelected: (bool selected) {
              if (selected) {
                onChanged(null);
              }
            },
          ),
          const SizedBox(width: 8),
          for (final source in TrendSource.values) ...[
            ChoiceChip(
              avatar: Icon(
                source.icon,
                size: 17,
              ),
              label: Text(source.label),
              selected: selectedSource == source,
              onSelected: (bool selected) {
                if (selected) {
                  onChanged(source);
                }
              },
            ),
            const SizedBox(width: 8),
          ],
        ],
      ),
    );
  }
}

情報源を変更すると、trendRequestProviderが更新されます。

rawTrendItemsProviderは取得条件を監視しているため、自動的にSupabaseへ再接続します。

Mastodonを選択
       ↓
TrendRequest.sourceを変更
       ↓
TrendListNotifierを再構築
       ↓
source = mastodon_statusで取得
       ↓
画面を更新

6.6 キーワードで検索する

キーワード検索は、取得済みのデータに対して行います。

検索対象は次の3項目です。

タイトル
本文・概要
投稿者・配信元

英字については、大文字と小文字を区別しません。

Flutter
flutter
FLUTTER

すべて同じ検索条件として扱う

表示用一覧Providerを作成する

次のファイルを作成します。

app/lib/features/trends/presentation/providers/visible_trend_items_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../domain/models/trend_item.dart';
import '../../../bookmarks/presentation/providers/bookmark_ids_notifier.dart';
import 'trend_display_filter_notifier.dart';
import 'trend_list_notifier.dart';

/// 役割:
/// 取得済み一覧へキーワードとブックマーク条件を適用する。
final visibleTrendItemsProvider =
    Provider<AsyncValue<List<TrendItem>>>((ref) {
  final itemsState = ref.watch(
    rawTrendItemsProvider,
  );

  final filter = ref.watch(
    trendDisplayFilterProvider,
  );

  final bookmarkState = ref.watch(
    bookmarkIdsProvider,
  );

  final bookmarkedIds =
      bookmarkState.asData?.value ??
      const <String>{};

  return itemsState.whenData((items) {
    final normalizedKeyword =
        filter.keyword.toLowerCase();

    final filteredItems = items.where((item) {
      if (normalizedKeyword.isNotEmpty &&
          !item.searchableText.contains(
            normalizedKeyword,
          )) {
        return false;
      }

      if (filter.onlyBookmarked &&
          !bookmarkedIds.contains(item.id)) {
        return false;
      }

      return true;
    }).toList(growable: false);

    return filteredItems;
  });
});

検索キーワードを変更しても、Supabaseへの再問い合わせは発生しません。

検索文字を入力
      ↓
TrendDisplayFilterだけを変更
      ↓
取得済みListを絞り込む
      ↓
画面を更新

検索フィールドを作成する

次のファイルを作成します。

app/lib/features/trends/presentation/widgets/trend_search_field.dart
import 'package:flutter/material.dart';

/// 役割:
/// トレンド一覧の検索キーワードを入力する。
final class TrendSearchField extends StatelessWidget {
  const TrendSearchField({
    required this.controller,
    required this.onChanged,
    required this.onClear,
    super.key,
  });

  final TextEditingController controller;
  final ValueChanged<String> onChanged;
  final VoidCallback onClear;

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: controller,
      onChanged: onChanged,
      textInputAction: TextInputAction.search,
      decoration: InputDecoration(
        hintText: 'キーワードで検索',
        prefixIcon: const Icon(Icons.search),
        suffixIcon: controller.text.isEmpty
            ? null
            : IconButton(
                tooltip: '検索をクリア',
                onPressed: onClear,
                icon: const Icon(Icons.clear),
              ),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(14),
        ),
      ),
    );
  }
}

TextFieldの入力中にクリアボタンを更新するには、親WidgetをStatefulWidgetにするか、ValueListenableBuilderを使います。

後ほど作成するTrendPageでは、ConsumerStatefulWidgetを使用します。


6.7 話題度順・新着順を切り替える

一覧では、次の2つの並び替え方法を用意します。

話題度順
└─ trend_scoreが高い順

新着順
└─ published_atが新しい順

並び替えボタンを作成する

次のファイルを作成します。

app/lib/features/trends/presentation/widgets/trend_sort_selector.dart
import 'package:flutter/material.dart';

import '../../domain/models/trend_sort.dart';

/// 役割:
/// 話題度順と新着順を切り替える。
final class TrendSortSelector extends StatelessWidget {
  const TrendSortSelector({
    required this.selectedSort,
    required this.onChanged,
    super.key,
  });

  final TrendSort selectedSort;
  final ValueChanged<TrendSort> onChanged;

  @override
  Widget build(BuildContext context) {
    return SegmentedButton<TrendSort>(
      segments:
          const <ButtonSegment<TrendSort>>[
        ButtonSegment<TrendSort>(
          value: TrendSort.trendScore,
          icon: Icon(Icons.trending_up),
          label: Text('話題度順'),
        ),
        ButtonSegment<TrendSort>(
          value: TrendSort.newest,
          icon: Icon(Icons.schedule),
          label: Text('新着順'),
        ),
      ],
      selected: <TrendSort>{
        selectedSort,
      },
      onSelectionChanged:
          (Set<TrendSort> selections) {
        if (selections.isEmpty) {
          return;
        }

        onChanged(selections.first);
      },
    );
  }
}

並び替え方法を変更すると、Supabaseから選択した順番で再取得します。

Repository側でも再度並び替えるため、公開日時がnullのデータは末尾へ移動します。


6.8 元投稿を外部ブラウザで開く

投稿カードをタップしたときは、Mastodon、Bluesky、RSS記事、YouTubeなどの元ページを開きます。

アプリ内に投稿全文を複製するのではなく、情報元へ移動できるようにします。

外部リンクサービスを作成する

次のファイルを作成します。

app/lib/core/services/external_link_service.dart
import 'package:url_launcher/url_launcher.dart';

/// 役割:
/// HTTPまたはHTTPSのURLを外部ブラウザで開く。
final class ExternalLinkService {
  const ExternalLinkService();

  /// 役割:
  /// 文字列URLを検証し、外部アプリで開く。
  ///
  /// 入力:
  /// 元投稿または元記事のURL。
  ///
  /// 出力:
  /// 正常に開けた場合は何も返さない。
  /// 開けない場合は例外を発生させる。
  Future<void> open(String value) async {
    final uri = Uri.tryParse(
      value.trim(),
    );

    if (uri == null ||
        !uri.hasScheme ||
        (uri.scheme != 'https' &&
            uri.scheme != 'http')) {
      throw const FormatException(
        'URLが正しくありません。',
      );
    }

    final launched = await launchUrl(
      uri,
      mode: LaunchMode.externalApplication,
    );

    if (!launched) {
      throw StateError(
        '外部ブラウザを開けませんでした。',
      );
    }
  }
}

Providerを作成する

次のファイルを作成します。

app/lib/core/providers/service_providers.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../services/external_link_service.dart';

/// 役割:
/// 外部リンクを開くサービスを提供する。
final externalLinkServiceProvider =
    Provider<ExternalLinkService>((ref) {
  return const ExternalLinkService();
});

URLを開けなかった場合は、画面上にエラーを表示します。

例外を無視すると、ボタンを押しても何も起きないように見えるためです。


6.9 ブックマークを端末内に保存する

ブックマークは、Supabaseではなく利用者の端末内へ保存します。

ユーザー登録を必要とせず、無料で実装するためです。

保存するのは、投稿全体ではなくTrendItem.idだけです。

ブックマークデータ

[
  "bluesky:at://did:plc:example/app.bsky.feed.post/abc",
  "youtube:video-id",
  "rss:hash-value"
]

投稿内容はSupabaseから取得し、端末にはIDだけを保持します。

Bookmark Repositoryを定義する

次のファイルを作成します。

app/lib/features/bookmarks/domain/repositories/bookmark_repository.dart
/// 役割:
/// ブックマークIDの保存方法を抽象化する。
abstract interface class BookmarkRepository {
  /// 役割:
  /// 保存済みのTrendItem IDを取得する。
  ///
  /// 入力:
  /// なし。
  ///
  /// 出力:
  /// 重複のないID一覧。
  Future<Set<String>> loadIds();

  /// 役割:
  /// ブックマークIDを端末へ保存する。
  ///
  /// 入力:
  /// 保存するID一覧。
  ///
  /// 出力:
  /// 保存完了を表すFuture。
  Future<void> saveIds(Set<String> ids);
}

SharedPreferencesAsyncで保存する

次のファイルを作成します。

app/lib/features/bookmarks/data/repositories/local_bookmark_repository.dart
import 'package:shared_preferences/shared_preferences.dart';

import '../../domain/repositories/bookmark_repository.dart';

/// 役割:
/// SharedPreferencesを使ってブックマークIDを端末へ保存する。
final class LocalBookmarkRepository
    implements BookmarkRepository {
  LocalBookmarkRepository({
    SharedPreferencesAsync? preferences,
  }) : _preferences =
            preferences ??
            SharedPreferencesAsync();

  static const String _storageKey =
      'trend_bookmark_ids';

  final SharedPreferencesAsync _preferences;

  @override
  Future<Set<String>> loadIds() async {
    final storedIds =
        await _preferences.getStringList(
      _storageKey,
    );

    if (storedIds == null) {
      return const <String>{};
    }

    return Set<String>.unmodifiable(
      storedIds.where(
        (id) => id.trim().isNotEmpty,
      ),
    );
  }

  @override
  Future<void> saveIds(Set<String> ids) async {
    final sortedIds = ids.toList()
      ..sort();

    await _preferences.setStringList(
      _storageKey,
      sortedIds,
    );
  }
}

ブックマーク状態を管理する

次のファイルを作成します。

app/lib/features/bookmarks/presentation/providers/bookmark_ids_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../data/repositories/local_bookmark_repository.dart';
import '../../domain/repositories/bookmark_repository.dart';

/// 役割:
/// ブックマークRepositoryを提供する。
final bookmarkRepositoryProvider =
    Provider<BookmarkRepository>((ref) {
  return LocalBookmarkRepository();
});

/// 保存済みのTrendItem IDを提供する。
final bookmarkIdsProvider =
    AsyncNotifierProvider<
      BookmarkIdsNotifier,
      Set<String>
    >(
  BookmarkIdsNotifier.new,
);

/// 役割:
/// ブックマークの読み込み、追加、解除を管理する。
final class BookmarkIdsNotifier
    extends AsyncNotifier<Set<String>> {
  @override
  Future<Set<String>> build() async {
    final repository = ref.watch(
      bookmarkRepositoryProvider,
    );

    return repository.loadIds();
  }

  /// 役割:
  /// 指定IDのブックマーク状態を反転する。
  ///
  /// 入力:
  /// TrendItemのID。
  ///
  /// 出力:
  /// 保存完了を表すFuture。
  Future<void> toggle(String id) async {
    final normalizedId = id.trim();

    if (normalizedId.isEmpty) {
      throw const FormatException(
        'ブックマークIDが空です。',
      );
    }

    final previousIds =
        state.asData?.value ??
        const <String>{};

    final nextIds = <String>{
      ...previousIds,
    };

    if (!nextIds.add(normalizedId)) {
      nextIds.remove(normalizedId);
    }

    final immutableNextIds =
        Set<String>.unmodifiable(
      nextIds,
    );

    state = AsyncData<Set<String>>(
      immutableNextIds,
    );

    try {
      final repository = ref.read(
        bookmarkRepositoryProvider,
      );

      await repository.saveIds(
        immutableNextIds,
      );
    } on Object catch (error, stackTrace) {
      state = AsyncData<Set<String>>(
        previousIds,
      );

      Error.throwWithStackTrace(
        error,
        stackTrace,
      );
    }
  }
}

ブックマークボタンを押した直後にUIを更新し、その後で端末へ保存しています。

保存に失敗した場合は、変更前の状態へ戻します。

ブックマークを押す
       ↓
画面上ではすぐに追加
       ↓
端末へ保存
       ↓
保存成功
└─ そのまま維持

保存失敗
└─ 変更前へ戻す

ブックマークの制限

SharedPreferencesは、少量の設定値やID一覧を保存する用途に向いています。

次のデータを大量に保存する用途には使用しません。

  • 投稿本文全体
  • 画像ファイル
  • 数千件以上の詳細データ
  • 複雑な検索履歴
  • オフライン閲覧用の記事全文

大規模なローカル保存が必要になった場合は、SQLiteなどのローカルデータベースを検討します。


トレンド一覧画面を完成させる

ここまで作成したProviderとWidgetを一つの画面へまとめます。

次のファイルを作成します。

app/lib/features/trends/presentation/pages/trend_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../../../core/providers/service_providers.dart';
import '../../../bookmarks/presentation/providers/bookmark_ids_notifier.dart';
import '../../domain/models/trend_sort.dart';
import '../../domain/models/trend_source.dart';
import '../providers/trend_display_filter_notifier.dart';
import '../providers/trend_list_notifier.dart';
import '../providers/trend_request_notifier.dart';
import '../providers/visible_trend_items_provider.dart';
import '../widgets/trend_card.dart';
import '../widgets/trend_search_field.dart';
import '../widgets/trend_sort_selector.dart';
import '../widgets/trend_source_filter.dart';

/// 役割:
/// トレンド一覧、検索、絞り込み、ブックマークを表示する。
final class TrendPage
    extends ConsumerStatefulWidget {
  const TrendPage({super.key});

  @override
  ConsumerState<TrendPage> createState() {
    return _TrendPageState();
  }
}

final class _TrendPageState
    extends ConsumerState<TrendPage> {
  final TextEditingController
      _searchController =
      TextEditingController();

  @override
  void dispose() {
    _searchController.dispose();
    super.dispose();
  }

  /// 役割:
  /// 元投稿を外部ブラウザで開く。
  ///
  /// 入力:
  /// 開くURL。
  ///
  /// 出力:
  /// 開く処理の完了を表すFuture。
  Future<void> _openOriginalUrl(
    String url,
  ) async {
    try {
      final service = ref.read(
        externalLinkServiceProvider,
      );

      await service.open(url);
    } on Object catch (error) {
      if (!mounted) {
        return;
      }

      ScaffoldMessenger.of(context)
        ..hideCurrentSnackBar()
        ..showSnackBar(
          SnackBar(
            content: Text(
              '元ページを開けませんでした。$error',
            ),
          ),
        );
    }
  }

  /// 役割:
  /// ブックマーク状態を切り替える。
  ///
  /// 入力:
  /// TrendItemのID。
  ///
  /// 出力:
  /// 保存完了を表すFuture。
  Future<void> _toggleBookmark(
    String id,
  ) async {
    try {
      await ref
          .read(bookmarkIdsProvider.notifier)
          .toggle(id);
    } on Object catch (error) {
      if (!mounted) {
        return;
      }

      ScaffoldMessenger.of(context)
        ..hideCurrentSnackBar()
        ..showSnackBar(
          SnackBar(
            content: Text(
              'ブックマークを保存できませんでした。$error',
            ),
          ),
        );
    }
  }

  /// 役割:
  /// 検索文字をクリアして一覧を再表示する。
  ///
  /// 入力:
  /// なし。
  ///
  /// 出力:
  /// なし。
  void _clearSearch() {
    _searchController.clear();

    ref
        .read(
          trendDisplayFilterProvider.notifier,
        )
        .setKeyword('');

    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    final request = ref.watch(
      trendRequestProvider,
    );

    final displayFilter = ref.watch(
      trendDisplayFilterProvider,
    );

    final visibleItems = ref.watch(
      visibleTrendItemsProvider,
    );

    final bookmarkIds =
        ref
            .watch(bookmarkIdsProvider)
            .asData
            ?.value ??
        const <String>{};

    return Scaffold(
      appBar: AppBar(
        title: const Text(
          'SNSトレンド',
        ),
        actions: <Widget>[
          IconButton(
            tooltip: displayFilter.onlyBookmarked
                ? 'すべて表示'
                : 'ブックマーク済みを表示',
            onPressed: () {
              ref
                  .read(
                    trendDisplayFilterProvider
                        .notifier,
                  )
                  .setOnlyBookmarked(
                    !displayFilter.onlyBookmarked,
                  );
            },
            icon: Icon(
              displayFilter.onlyBookmarked
                  ? Icons.bookmarks
                  : Icons.bookmarks_outlined,
            ),
          ),
        ],
      ),
      body: SafeArea(
        child: Column(
          children: <Widget>[
            Padding(
              padding: const EdgeInsets.fromLTRB(
                16,
                12,
                16,
                10,
              ),
              child: TrendSearchField(
                controller: _searchController,
                onChanged: (String value) {
                  ref
                      .read(
                        trendDisplayFilterProvider
                            .notifier,
                      )
                      .setKeyword(value);

                  setState(() {});
                },
                onClear: _clearSearch,
              ),
            ),
            TrendSourceFilter(
              selectedSource: request.source,
              onChanged:
                  (TrendSource? source) {
                ref
                    .read(
                      trendRequestProvider
                          .notifier,
                    )
                    .setSource(source);
              },
            ),
            Padding(
              padding: const EdgeInsets.fromLTRB(
                16,
                10,
                16,
                12,
              ),
              child: Row(
                children: <Widget>[
                  Expanded(
                    child: TrendSortSelector(
                      selectedSort: request.sort,
                      onChanged:
                          (TrendSort sort) {
                        ref
                            .read(
                              trendRequestProvider
                                  .notifier,
                            )
                            .setSort(sort);
                      },
                    ),
                  ),
                ],
              ),
            ),
            Expanded(
              child: visibleItems.when(
                loading: () {
                  return const Center(
                    child:
                        CircularProgressIndicator(),
                  );
                },
                error: (
                  Object error,
                  StackTrace stackTrace,
                ) {
                  return _TrendErrorView(
                    error: error,
                    onRetry: () {
                      ref
                          .read(
                            rawTrendItemsProvider
                                .notifier,
                          )
                          .reload();
                    },
                  );
                },
                data: (items) {
                  if (items.isEmpty) {
                    return _TrendEmptyView(
                      isFiltered:
                          displayFilter.keyword
                                  .isNotEmpty ||
                              displayFilter
                                  .onlyBookmarked ||
                              request.source != null,
                    );
                  }

                  return RefreshIndicator(
                    onRefresh: () {
                      return ref
                          .read(
                            rawTrendItemsProvider
                                .notifier,
                          )
                          .reload();
                    },
                    child: ListView.separated(
                      padding:
                          const EdgeInsets.fromLTRB(
                        16,
                        4,
                        16,
                        32,
                      ),
                      physics:
                          const AlwaysScrollableScrollPhysics(),
                      itemCount: items.length,
                      separatorBuilder: (
                        BuildContext context,
                        int index,
                      ) {
                        return const SizedBox(
                          height: 12,
                        );
                      },
                      itemBuilder: (
                        BuildContext context,
                        int index,
                      ) {
                        final item = items[index];

                        return TrendCard(
                          item: item,
                          isBookmarked:
                              bookmarkIds.contains(
                            item.id,
                          ),
                          onOpen: () {
                            _openOriginalUrl(
                              item.originalUrl,
                            );
                          },
                          onToggleBookmark: () {
                            _toggleBookmark(
                              item.id,
                            );
                          },
                        );
                      },
                    ),
                  );
                },
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// 役割:
/// データ取得失敗時の表示を作成する。
final class _TrendErrorView
    extends StatelessWidget {
  const _TrendErrorView({
    required this.error,
    required this.onRetry,
  });

  final Object error;
  final VoidCallback onRetry;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: <Widget>[
            const Icon(
              Icons.cloud_off_outlined,
              size: 48,
            ),
            const SizedBox(height: 16),
            Text(
              'トレンド情報を取得できませんでした。',
              textAlign: TextAlign.center,
              style: Theme.of(context)
                  .textTheme
                  .titleMedium,
            ),
            const SizedBox(height: 8),
            Text(
              error.toString(),
              textAlign: TextAlign.center,
              style: Theme.of(context)
                  .textTheme
                  .bodySmall,
            ),
            const SizedBox(height: 16),
            FilledButton.icon(
              onPressed: onRetry,
              icon: const Icon(Icons.refresh),
              label: const Text('再読み込み'),
            ),
          ],
        ),
      ),
    );
  }
}

/// 役割:
/// 表示対象が0件の場合の案内を作成する。
final class _TrendEmptyView
    extends StatelessWidget {
  const _TrendEmptyView({
    required this.isFiltered,
  });

  final bool isFiltered;

  @override
  Widget build(BuildContext context) {
    return ListView(
      physics:
          const AlwaysScrollableScrollPhysics(),
      padding: const EdgeInsets.all(32),
      children: <Widget>[
        const SizedBox(height: 80),
        Icon(
          isFiltered
              ? Icons.search_off
              : Icons.inbox_outlined,
          size: 52,
        ),
        const SizedBox(height: 16),
        Text(
          isFiltered
              ? '条件に一致する情報がありません。'
              : 'トレンド情報がまだありません。',
          textAlign: TextAlign.center,
          style: Theme.of(context)
              .textTheme
              .titleMedium,
        ),
        const SizedBox(height: 8),
        Text(
          isFiltered
              ? '検索条件や情報源を変更してください。'
              : '収集処理が完了すると、ここに表示されます。',
          textAlign: TextAlign.center,
        ),
      ],
    );
  }
}

アプリ本体を作成する

次のファイルを作成します。

app/lib/app/app.dart
import 'package:flutter/material.dart';

import '../features/trends/presentation/pages/trend_page.dart';

/// 役割:
/// アプリ全体のテーマと最初の画面を定義する。
final class SnsTrendApp extends StatelessWidget {
  const SnsTrendApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SNSトレンド',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: const Color(
          0xFF315F9B,
        ),
        scaffoldBackgroundColor:
            const Color(0xFFF7F8FA),
        cardTheme: const CardThemeData(
          elevation: 0,
          margin: EdgeInsets.zero,
        ),
      ),
      home: const TrendPage(),
    );
  }
}

実装後のディレクトリ構成

第6ページまで実装すると、Flutter側は次のようになります。

app/lib/
├─ app/
│  └─ app.dart
│
├─ core/
│  ├─ config/
│  │  └─ app_environment.dart
│  ├─ formatters/
│  │  ├─ count_formatter.dart
│  │  └─ relative_time_formatter.dart
│  ├─ json/
│  │  └─ json_reader.dart
│  ├─ providers/
│  │  └─ service_providers.dart
│  └─ services/
│     └─ external_link_service.dart
│
├─ features/
│  ├─ bookmarks/
│  │  ├─ data/
│  │  │  └─ repositories/
│  │  │     └─ local_bookmark_repository.dart
│  │  ├─ domain/
│  │  │  └─ repositories/
│  │  │     └─ bookmark_repository.dart
│  │  └─ presentation/
│  │     └─ providers/
│  │        └─ bookmark_ids_notifier.dart
│  │
│  └─ trends/
│     ├─ data/
│     │  └─ repositories/
│     │     └─ supabase_trend_repository.dart
│     ├─ domain/
│     │  ├─ models/
│     │  │  ├─ trend_display_filter.dart
│     │  │  ├─ trend_item.dart
│     │  │  ├─ trend_request.dart
│     │  │  ├─ trend_sort.dart
│     │  │  └─ trend_source.dart
│     │  └─ repositories/
│     │     └─ trend_repository.dart
│     └─ presentation/
│        ├─ extensions/
│        │  └─ trend_source_presentation.dart
│        ├─ pages/
│        │  └─ trend_page.dart
│        ├─ providers/
│        │  ├─ trend_display_filter_notifier.dart
│        │  ├─ trend_list_notifier.dart
│        │  ├─ trend_repository_provider.dart
│        │  ├─ trend_request_notifier.dart
│        │  └─ visible_trend_items_provider.dart
│        └─ widgets/
│           ├─ trend_card.dart
│           ├─ trend_search_field.dart
│           ├─ trend_sort_selector.dart
│           └─ trend_source_filter.dart
│
└─ main.dart

アプリを起動する

最初にコードを整形します。

cd app

dart format .

静的解析を実行します。

flutter analyze

問題がなければ起動します。

flutter run \
  --dart-define-from-file=env/development.json

確認する内容

アプリ起動後、次の動作を確認します。

初期表示
└─ 話題度が高い順に表示される

情報源
└─ 選択したSNSだけが表示される

検索
└─ タイトル・本文・投稿者から絞り込める

並び替え
├─ 話題度順
└─ 新着順

元投稿
└─ 外部ブラウザで開く

ブックマーク
├─ アイコンが切り替わる
├─ アプリを再起動しても残る
└─ 保存済みだけを表示できる

更新
└─ 下方向へ引っ張ると再取得される

データが表示されない場合

次の順番で確認します。

1. Supabaseのtrend_itemsにデータがあるか
2. trend_itemsのRLSが有効か
3. anonへSELECTポリシーがあるか
4. SUPABASE_URLが正しいか
5. SUPABASE_PUBLISHABLE_KEYが正しいか
6. sourceの文字列がenumと一致しているか
7. collected_atが正しい日時形式か
8. FlutterのログにFormatExceptionが出ていないか

RLSポリシーを確認する

第4ページで、次の読み取りポリシーを作成しました。

create policy "Public can read trend items"
on public.trend_items
for select
to anon, authenticated
using (true);

このポリシーがない場合、Publishable keyからデータを読み取れません。

RLSを無効にするのではなく、必要な読み取り権限だけを設定します。


取得件数が増えた場合の注意

本教材では、最大300件を取得して画面内で検索します。

MVPとしては単純で扱いやすい構成ですが、データが増えた場合は次の問題が発生します。

  • 過去の投稿が取得範囲から外れる
  • 選択したキーワードの投稿が300件より後ろにある
  • 初回表示の通信量が増える
  • 端末での検索処理が重くなる
  • 画像読み込みが増える

本番運用では、次の改善を検討します。

ページネーション
├─ 20件ずつ取得
└─ 末尾で次のページを読む

データベース検索
├─ title
├─ text
└─ author_name

表示期間の制限
└─ 直近30日だけを取得

サムネイル制御
└─ 画面に近い画像だけを読み込む

7ページで完結するMVPでは、まず300件以内で正常に動作することを確認します。


まとめ

このページでは、Supabaseへ保存したトレンド情報をFlutterアプリから読み込み、利用者が確認できる一覧画面を作成しました。

Flutter側の処理は、次のように分離されています。

Supabase
    ↓
SupabaseTrendRepository
├─ データを取得する
├─ JSONを検証する
└─ TrendItemへ変換する
    ↓
TrendListNotifier
├─ 読み込み中
├─ 取得成功
└─ 取得失敗
    ↓
visibleTrendItemsProvider
├─ キーワード検索
└─ ブックマーク絞り込み
    ↓
TrendPage
├─ 一覧表示
├─ 情報源フィルター
├─ 並び替え
├─ 外部リンク
└─ ブックマーク

取得条件と画面内の検索条件も分離しました。

Supabaseへ再問い合わせする
├─ 情報源の変更
└─ 並び替えの変更

取得済みデータだけを絞り込む
├─ キーワード入力
└─ ブックマーク済み表示

これにより、検索文字を入力するたびにSupabaseへ接続することを防いでいます。

ブックマークには、投稿全体ではなくIDだけを保存しました。

端末内
└─ TrendItem.id

Supabase
└─ 最新の投稿内容

これで、情報収集、話題度計算、Supabase保存、Flutter表示までがつながりました。

次のページでは、GitHub Actionsの自動収集、API使用量、無料枠、古いデータの削除、投稿削除への対応、Android・iOSでの動作確認を行い、アプリを完成させます。

FAQ

よくある質問

Flutterでトレンド一覧アプリを作るは医療関係者向けだけの内容ですか。
医療分野の例が含まれる場合もありますが、医療関係者だけに限定した内容ではありません。生成AI、AI活用、DX、業務改善、プロトタイプ開発など、一般的なAI学習の事例として読める内容です。
AI初心者でも読めますか。
はい。AIをこれから学ぶ方、数学が苦手な方、仕事でAIを使いたい方にも読み進めやすいように、教材の章と節の流れに沿って整理しています。
サムネイル画像は必ず表示されますか。
はい。教材にcoverUrlが設定されている場合はその画像を表示し、未設定の場合は代替サムネイル画像を表示します。
Flutterアプリケーション開発概論のほかの章も読めますか。
はい。教材トップから章立てを確認でき、前後の節へもページ下部のナビゲーションから移動できます。