A npm client app as minimal flutter project example
- hooks_riverpod for state management
- freezed for serializing (deserializing) json objects
- dio for network request
- shared_preferences for local database
- slang for localization
- install flutter
- clone this repository
- run
cd $PATH_TO_REPOSITORY
- run
flutter pub get
- run
dart run build_runner build
- run
dart run slang
- run
flutter run
New to flutter? See: How to install flutter app on your device
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
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
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
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
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
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:
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:
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:
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:
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:
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.
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.
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.