Initial commit

This commit is contained in:
Henry Hiles 2025-01-03 18:33:59 -05:00
parent 32e425f961
commit e94d583b8b
67 changed files with 2516 additions and 698 deletions

View file

@ -0,0 +1,17 @@
import 'package:canal/widgets/loading.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
extension BetterWhen<T> on AsyncValue<T> {
Widget betterWhen({
required Widget Function(T) data,
Widget Function() loading = Loading.new,
}) =>
when(
data: data,
error: (error, stackTrace) =>
Text("error"), // TODO: Better err reporting
loading: loading,
skipLoadingOnRefresh: false,
);
}

14
lib/main.dart Normal file
View file

@ -0,0 +1,14 @@
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:canal/widgets/app.dart';
import 'package:window_size/window_size.dart';
import 'package:yaru/yaru.dart';
void main() async {
await YaruWindowTitleBar.ensureInitialized();
WidgetsFlutterBinding.ensureInitialized();
setWindowMinSize(const Size.square(500));
runApp(const ProviderScope(child: App()));
}

View file

@ -0,0 +1,11 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:yaru/yaru.dart';
part "decorations.freezed.dart";
@freezed
class Decorations with _$Decorations {
const factory Decorations({
required List<YaruWindowControlType> leading,
required List<YaruWindowControlType> trailing,
}) = _Decorations;
}

13
lib/models/package.dart Normal file
View file

@ -0,0 +1,13 @@
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part "package.freezed.dart";
@freezed
class Package with _$Package {
const factory Package({
required String name,
required String author,
required Color color,
required Widget icon,
}) = _Package;
}

8
lib/models/tab.dart Normal file
View file

@ -0,0 +1,8 @@
import 'package:flutter/widgets.dart';
abstract class TabPage extends Widget {
const TabPage({super.key});
String get title;
IconData get icon;
}

View file

@ -0,0 +1,10 @@
import 'package:gsettings/gsettings.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'button_layout_provider.g.dart';
@riverpod
Future<String> buttonLayout(Ref ref) =>
GSettings('org.gnome.desktop.wm.preferences')
.get("button-layout")
.then((dbusValue) => dbusValue.asString());

View file

@ -0,0 +1,28 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import "package:riverpod_annotation/riverpod_annotation.dart";
import 'package:canal/models/decorations.dart';
import 'package:canal/providers/button_layout_provider.dart';
import 'package:yaru/yaru.dart';
import "package:collection/collection.dart";
part 'decorations_provider.g.dart';
@riverpod
Decorations decorations(Ref ref) {
List<YaruWindowControlType> parse(String section) => section
.split(",")
.map(
(button) => YaruWindowControlType.values.firstWhereOrNull(
(element) => element.name == button,
),
)
.whereNotNull()
.toList();
final buttons = ref.watch(buttonLayoutProvider).requireValue;
final [leading, trailing] = buttons.split(":");
return Decorations(
leading: parse(leading),
trailing: parse(trailing),
);
}

View file

@ -0,0 +1,13 @@
import 'package:canal/providers/ytmusic_provider.dart';
import 'package:dart_ytmusic_api/dart_ytmusic_api.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part "home_sections_provider.g.dart";
@riverpod
Future<IList<HomeSection>> homeSections(Ref ref) async {
final yt = await ytmusic(ref);
return IList(await yt.getHomeSections());
}

View file

@ -0,0 +1,15 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part "warmup_provider.g.dart";
@riverpod
Future<void> warmup(
Ref ref,
IList<AutoDisposeFutureProvider> providers,
) async =>
await Future.wait(
providers.map(
(provider) => ref.watch(provider.future),
),
);

View file

@ -0,0 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:dart_ytmusic_api/yt_music.dart';
part "ytmusic_provider.g.dart";
@riverpod
Future<YTMusic> ytmusic(Ref ref) async => YTMusic().initialize();

View file

@ -0,0 +1,17 @@
import 'package:canal/widgets/appbar.dart';
import 'package:flutter/material.dart';
class AlbumPage extends StatelessWidget {
const AlbumPage({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: Appbar(title: "Album"),
body: Center(
child: Text(
"Coming soon...",
style: Theme.of(context).textTheme.displayMedium,
),
),
);
}

View file

@ -0,0 +1,17 @@
import 'package:canal/widgets/appbar.dart';
import 'package:flutter/material.dart';
class PlaylistPage extends StatelessWidget {
const PlaylistPage({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: Appbar(title: "Playlist"),
body: Center(
child: Text(
"Coming soon...",
style: Theme.of(context).textTheme.displayMedium,
),
),
);
}

View file

@ -0,0 +1,22 @@
import 'package:flutter/material.dart';
import 'package:canal/models/tab.dart';
class AccountTab extends StatelessWidget implements TabPage {
const AccountTab({super.key});
@override
IconData get icon => Icons.person;
@override
String get title => "Account";
@override
Widget build(BuildContext context) => Scaffold(
body: Center(
child: Text(
"Coming soon...",
style: Theme.of(context).textTheme.displayMedium,
),
),
);
}

View file

@ -0,0 +1,64 @@
import 'package:canal/helpers/extension_helper.dart';
import 'package:canal/providers/home_sections_provider.dart';
import 'package:canal/screens/album_page.dart';
import 'package:canal/screens/playlist_page.dart';
import 'package:canal/widgets/thumbnail.dart';
import 'package:dart_ytmusic_api/dart_ytmusic_api.dart';
import 'package:flutter/material.dart';
import 'package:canal/models/tab.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:yaru/yaru.dart';
class HomeTab extends ConsumerWidget implements TabPage {
const HomeTab({super.key});
@override
IconData get icon => Icons.home;
@override
String get title => "Home";
@override
Widget build(BuildContext context, WidgetRef ref) => ref
.watch(homeSectionsProvider)
.betterWhen(
data: (sections) => ListView(
padding: EdgeInsets.symmetric(vertical: 4),
children: sections
.where(
(element) => element.contents.isNotEmpty,
)
.map(
(section) => YaruSection(
margin: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
headline: Text(section.title),
child: SizedBox(
height: 262,
child: ListView(
itemExtent: 268,
scrollDirection: Axis.horizontal,
children: section.contents
.map((song) => Padding(
padding:
EdgeInsets.only(right: 12, bottom: 4, top: 2),
child: Thumbnail(
url: song.thumbnails.first.url,
onClick: () => Navigator.of(context)
.push(MaterialPageRoute(
builder: (_) => switch (song) {
PlaylistDetailed _ =>
PlaylistPage(),
AlbumDetailed _ => AlbumPage(),
_ => throw "Unknown type",
})),
)))
.toList(),
),
),
),
)
.toList(),
),
);
}

View file

@ -0,0 +1,28 @@
import 'package:flutter/material.dart';
import 'package:canal/models/tab.dart';
import 'package:yaru/yaru.dart';
class SearchTab extends StatelessWidget implements TabPage {
const SearchTab({super.key});
@override
IconData get icon => Icons.search;
@override
String get title => "Search";
@override
Widget build(BuildContext context) => ListView(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
children: [
SegmentedButton(
segments: [
ButtonSegment(value: 0, label: Text("Foo")),
ButtonSegment(value: 1, label: Text("Bar")),
ButtonSegment(value: 2, label: Text("Foobar"))
],
selected: {0},
)
],
);
}

60
lib/widgets/app.dart Normal file
View file

@ -0,0 +1,60 @@
import 'package:adwaita/adwaita.dart';
import 'package:canal/helpers/extension_helper.dart';
import 'package:canal/models/tab.dart';
import 'package:canal/providers/ytmusic_provider.dart';
import 'package:canal/screens/tabs/account.dart';
import 'package:canal/screens/tabs/home.dart';
import 'package:canal/screens/tabs/search.dart';
import 'package:canal/widgets/appbar.dart';
import "package:flutter/material.dart";
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:yaru/yaru.dart';
import 'package:canal/providers/button_layout_provider.dart';
import 'package:canal/providers/warmup_provider.dart';
const List<TabPage> tabs = [HomeTab(), SearchTab(), AccountTab()];
class App extends HookConsumerWidget {
const App({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = useState(0);
return MaterialApp(
home: Scaffold(
body: ref
.watch(warmupProvider(IList([
buttonLayoutProvider,
ytmusicProvider,
])))
.betterWhen(
data: (_) => YaruDetailPage(
appBar: const Appbar(title: "Canal"),
body: tabs[selected.value],
bottomNavigationBar: NavigationBar(
destinations: tabs
.map(
(tab) => NavigationDestination(
icon: Icon(tab.icon),
label: tab.title,
),
)
.toList(),
selectedIndex: selected.value,
onDestinationSelected: (index) => selected.value = index,
),
),
),
),
theme: AdwaitaThemeData.light().copyWith(
textTheme: const YaruThemeData().theme?.textTheme,
),
darkTheme: AdwaitaThemeData.dark().copyWith(
textTheme: const YaruThemeData().darkTheme?.textTheme,
),
debugShowCheckedModeBanner: false,
);
}
}

66
lib/widgets/appbar.dart Normal file
View file

@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:canal/providers/decorations_provider.dart';
import 'package:yaru/yaru.dart';
class Appbar extends ConsumerWidget implements PreferredSizeWidget {
final String title;
const Appbar({
required this.title,
super.key,
});
@override
Size get preferredSize => const YaruWindowTitleBar().preferredSize;
@override
Widget build(BuildContext context, WidgetRef ref) {
final window = YaruWindow.of(context);
List<Widget> getControl(List<YaruWindowControlType> types) => [
const SizedBox(width: 6),
...types.map(
(type) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: YaruWindowControl(
type: type,
onTap: switch (type) {
YaruWindowControlType.close => window.close,
YaruWindowControlType.maximize => () => window.state().then(
(state) => state.isMaximized!
? window.restore()
: window.maximize(),
),
YaruWindowControlType.minimize => window.minimize,
YaruWindowControlType.restore => window.restore,
},
),
),
),
const SizedBox(width: 6),
];
final decorations = ref.watch(decorationsProvider);
return YaruWindowTitleBar(
backgroundColor: Colors.transparent,
title: Text(title),
leading: Row(children: [
SizedBox(width: 12),
if (Navigator.of(context).canPop())
BackButton(
style: ButtonStyle(
iconSize: WidgetStatePropertyAll(20),
minimumSize: WidgetStatePropertyAll(Size.square(0)),
padding: WidgetStatePropertyAll(EdgeInsets.zero),
),
),
...getControl(decorations.leading)
]),
buttonPadding: const EdgeInsets.symmetric(horizontal: 12),
actions: getControl(decorations.trailing),
border: BorderSide.none,
style: YaruTitleBarStyle.undecorated,
);
}
}

13
lib/widgets/loading.dart Normal file
View file

@ -0,0 +1,13 @@
import "package:flutter/material.dart";
class Loading extends StatelessWidget {
const Loading({super.key});
@override
Widget build(BuildContext context) => const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
);
}

View file

@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:canal/models/package.dart';
class PackageCard extends StatelessWidget {
final Package package;
const PackageCard(this.package, {super.key});
@override
Widget build(BuildContext context) => Card(
color: package.color,
child: ListTile(
title: Text(
package.name,
style: Theme.of(context).textTheme.titleLarge,
),
subtitle: Text(
package.author,
style: Theme.of(context).textTheme.titleSmall,
),
leading: Padding(
padding: const EdgeInsets.only(right: 4),
child: SizedBox(
width: 48,
child: package.icon,
),
),
),
);
}

View file

@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
class Thumbnail extends StatelessWidget {
final String url;
final VoidCallback onClick;
const Thumbnail({required this.url, required this.onClick, super.key});
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.all(Radius.circular(16)),
child: InkWell(
onTap: onClick,
child: Image.network(
url,
fit: BoxFit.fill,
)),
);
}