Skip to content

Latest commit

 

History

History
451 lines (354 loc) · 13.1 KB

README.md

File metadata and controls

451 lines (354 loc) · 13.1 KB

📦 npm client

Codacy Badge CodeFactor Test Preview

A npm client app as minimal flutter project example

Using

  • hooks_riverpod for state management
  • freezed for serializing (deserializing) json objects
  • dio for network request
  • shared_preferences for local database
  • slang for localization

example

How to install

  1. install flutter
  2. clone this repository
  3. run cd $PATH_TO_REPOSITORY
  4. run flutter pub get
  5. run dart run build_runner build
  6. run dart run slang
  7. run flutter run

New to flutter? See: How to install flutter app on your device

Features

🛜 Real time fetch

Real-time fetching with dio and riverpod. Listen to TextEditingController to rebuild the widget.

Show codes
final searchController = useTextEditingController(text: initialSearchText);
useListenable(searchController);

See: packages_page.dart

Real time fetch

👌 Pull to refresh

Pull to refresh with RefreshIndicator. By returning .future to onRefresh, the indicator will continue to be displayed until the data fetching is complete.

Show codes
RefreshIndicator(
  onRefresh: () => ref.refresh(packagesProvider(
    search: searchText,
    debounce: false,
  ).future),
  child: ListView.separated(
    separatorBuilder: (_, __) => const Divider(),
    itemCount: sortedPackages.length,
    itemBuilder: (_, int i) => PackageItem(sortedPackages[i]),
  ),
);

See: packages_page.dart

Pull to refresh

📊 Sort

Sort with riverpod.

Show codes
@riverpod
class Sort extends _$Sort {
  @override
  ScoreType? build() => null;

  void update(ScoreType type) => state = type;
}

@riverpod
Future<List<Package>> sortedPackages(SortedPackagesRef ref,
    {required String search}) async {
  final packages = await ref.watch(packagesProvider(search: search).future);
  final sort = ref.watch(sortProvider);

  return sort == null
      ? List.of(packages)
      : packages.sortedByCompare(
          (package) => sort.getValue(package.score), (a, b) => b.compareTo(a));
}

See: packages_page.dart

Pull to refresh

☁️ Empty state

Switching widget according to status with AsyncValue.

Show codes
return sortedPackages.isEmpty
    ? SingleChildScrollView(
        child: EmptyImage(text: l10n.packagesPage.packageNotFound),
      )
    : RefreshIndicator(
        onRefresh: () async => ref.refresh(packagesProvider(
          search: searchText,
          debounce: false,
        ).future),
        child: ListView.separated(
          separatorBuilder: (_, __) => const Divider(),
          itemCount: sortedPackages.length,
          itemBuilder: (_, int i) => PackageItem(sortedPackages[i]),
        ),
      );

See: packages_page.dart

Empty state

🪽 Jump to repository

Jumping to repository with url_launcher.

Show codes
class LinkText extends StatelessWidget {
  const LinkText(
    this.url, {
    this.text,
    super.key,
  });

  final String? text;
  final String url;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () async => launchUrl(Uri.parse(url)),
      child: Text(
        text ?? url,
        style: const TextStyle(
          fontWeight: FontWeight.bold,
          decoration: TextDecoration.underline,
        ),
      ),
    );
  }
}

See: link_text.dart

Jump to repository

🔍 See package details

Getting package details for requesting api with dio and freezed.

Show codes
@riverpod
Dio dio(DioRef ref) => Dio();

@riverpod
Future<PackageDetails> packageDetails(PackageDetailsRef ref,
    {required String id}) async {
  final response = await ref.watch(dioProvider).getUri<Json>(
        Uri.parse('https://registry.npmjs.org/$id'),
      );
  return PackageDetails.fromJson(response.data!);
}

@freezed
class PackageDetails with _$PackageDetails {
  const PackageDetails._();

  const factory PackageDetails({
    required final String name,
    final String? description,
    final String? homepage,
    final String? repository,
    final String? readme,
    final List<String>? keywords,
    final String? license,
  }) = _PackageDetails;

  factory PackageDetails.fromJson(Json json) {
    final git = json['repository']?['url'] as String?;

    return PackageDetails(
      name: json['name'],
      description: json['description'],
      keywords: ListX.fromOrNull<String>(json['keywords']),
      license: json['license'],
      homepage: json['homepage'],
      repository: git == null ? null : Format.urlFromGit(git),
      readme: json['readme'],
    );
  }
}

See:

See package details

🌙 Dark mode

Dynamic theming with riverpod and shared_preferences. Use ref.invalidateSelf() for SSOT design.

Show codes
@Riverpod(keepAlive: true)
SharedPreferences sharedPreferences(SharedPreferencesRef ref) =>
    throw UnimplementedError('SharedPreferences is not overridden.');

@riverpod
class IsDarkMode extends _$IsDarkMode {
  static const _key = 'isDarkMode';
  @override
  bool build() {
    final prefs = ref.watch(sharedPreferencesProvider);
    return prefs.getBool(_key) ?? false;
  }

  Future<void> toggle() async {
    final prefs = ref.read(sharedPreferencesProvider);
    await prefs.setBool(_key, !state);
    ref.invalidateSelf();
  }
}

See:

Dark mode

🗣️ Localization

Dynamic localization with slang, riverpod and shared_preferences. Use ref.invalidateSelf() for SSOT design.

Show codes
@riverpod
StringsEn l10n(L10nRef ref) => ref.watch(languageProvider).stringsEn;

@riverpod
class Language extends _$Language {
  static const _key = 'language';
  @override
  LanguageType build() {
    final prefs = ref.watch(sharedPreferencesProvider);
    return LanguageType.fromName(prefs.getString(_key) ?? LanguageType.en.name);
  }

  Future<void> update(LanguageType type) async {
    final prefs = ref.read(sharedPreferencesProvider);
    await prefs.setString(_key, type.name);
    ref.invalidateSelf();
  }
}

enum LanguageType {
  en,
  ja;

  StringsEn get stringsEn => switch (this) {
        ja => AppLocale.ja.build(),
        en => AppLocale.en.build(),
      };

  static LanguageType fromName(String name) =>
      LanguageType.values.firstWhere((e) => e.name == name);

  @override
  String toString() => switch (this) {
        en => 'English',
        ja => '日本語',
      };
}

See:

Localization

🪄 Responsive design

Dynamic layout for different screen sizes.

Show codes
extension BuildContextX on BuildContext {
  bool get isLargeScreen => MediaQuery.of(this).size.width > 600;
}

child: context.isLargeScreen
    ? Row(
        children: [
          const _SortPanel(),
          const VerticalDivider(),
          Expanded(
            child: _PackageItems(searchText: searchController.text),
          ),
        ],
      )
    : NestedScrollView(
        headerSliverBuilder: (_, __) => [
          const SliverAppBar(
            surfaceTintColor: Colors.transparent,
            toolbarHeight: 200,
            title: _SortPanel(),
          )
        ],
        body: _PackageItems(searchText: searchController.text),
      ),

See:

Responsive

✅ Auto testing

Auto testing with github actions.

Show codes
jobs:
  test:
    timeout-minutes: 30
    strategy:
      fail-fast: false
    runs-on: macos-latest
    steps:
      - name: Check out
        uses: actions/checkout@v4

      - name: Setup JDK
        uses: actions/setup-java@v3
        with:
          java-version: 11
          distribution: temurin
          cache: gradle

      - name: Setup Flutter SDK
        timeout-minutes: 10
        uses: subosito/flutter-action@v2
        with:
          channel: beta

      - name: Flutter Pub get
        run: flutter pub get

      - name: Flutter Analyze
        run: flutter analyze

      - name: Unit Test
        timeout-minutes: 5
        run: flutter test test/unit_test.dart

      - name: Widget Test
        timeout-minutes: 5
        run: flutter test test/widget_test.dart

      - name: Golden Test
        timeout-minutes: 5
        run: flutter test test/golden_test.dart

      - name: Build iOS
        timeout-minutes: 10
        run: flutter build ios --no-codesign

      - name: Build Android
        timeout-minutes: 10
        run: flutter build appbundle

See:

Auto test

Discussion about folder structure

The project uses a feature-first folder structure.
This will depend on the project, but I find it best to put things close together that are closely related.

Why is there no layer folder like "presentation"

This project is minimal and even if you create a layered folder, only one file can go in that folder.
Putting them in a folder then would only needlessly add to the hierarchy and make it harder to see.
This should be best suited for the size of the project.

Should Providers and UI always be placed in separate files?

I don't think so. Look at packages_page.dart for example, which is a UI file, but with providers. My basic idea is put things close to each other in close proximity. I don't think it's necessary to put them in a data (domain) layer file, since all the providers here are only for this packages page. However, this should also be changed depending on the project. If it is a large project, these providers might be placed in a file called ~controller. But this seems a bit far from the declarative UI philosophy.